@jantstack/adonis-authz 1.0.2 → 2.0.0-alpha.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/LICENSE +1 -1
- package/README.md +417 -46
- package/build/commands/authz_catalog_diff.d.ts +28 -0
- package/build/commands/authz_catalog_diff.d.ts.map +1 -0
- package/build/commands/authz_catalog_diff.js +67 -0
- package/build/commands/authz_catalog_diff.js.map +1 -0
- package/build/commands/authz_catalog_sync.d.ts +20 -0
- package/build/commands/authz_catalog_sync.d.ts.map +1 -0
- package/build/commands/authz_catalog_sync.js +58 -0
- package/build/commands/authz_catalog_sync.js.map +1 -0
- package/build/commands/main.d.ts +2 -0
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +2 -0
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_import.d.ts +8 -2
- package/build/commands/openfga_import.d.ts.map +1 -1
- package/build/commands/openfga_import.js +29 -6
- package/build/commands/openfga_import.js.map +1 -1
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +2 -1
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/index.d.ts +51 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +43 -3
- package/build/index.js.map +1 -1
- package/build/src/catalog.d.ts +249 -2
- package/build/src/catalog.d.ts.map +1 -1
- package/build/src/catalog.js +709 -59
- package/build/src/catalog.js.map +1 -1
- package/build/src/catalog_cache.d.ts +300 -0
- package/build/src/catalog_cache.d.ts.map +1 -0
- package/build/src/catalog_cache.js +656 -0
- package/build/src/catalog_cache.js.map +1 -0
- package/build/src/clock.d.ts +24 -0
- package/build/src/clock.d.ts.map +1 -0
- package/build/src/clock.js +7 -0
- package/build/src/clock.js.map +1 -0
- package/build/src/define_config.d.ts +102 -6
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/backend_guard.d.ts +92 -0
- package/build/src/drivers/backend_guard.d.ts.map +1 -0
- package/build/src/drivers/backend_guard.js +221 -0
- package/build/src/drivers/backend_guard.js.map +1 -0
- package/build/src/drivers/database_driver.d.ts +242 -15
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +692 -127
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +341 -27
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +1052 -264
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/sql_expiry.d.ts +53 -0
- package/build/src/drivers/sql_expiry.d.ts.map +1 -0
- package/build/src/drivers/sql_expiry.js +66 -0
- package/build/src/drivers/sql_expiry.js.map +1 -0
- package/build/src/errors.d.ts +366 -0
- package/build/src/errors.d.ts.map +1 -0
- package/build/src/errors.js +387 -0
- package/build/src/errors.js.map +1 -0
- package/build/src/expiry.d.ts +27 -0
- package/build/src/expiry.d.ts.map +1 -0
- package/build/src/expiry.js +50 -0
- package/build/src/expiry.js.map +1 -0
- package/build/src/hierarchical_resolver.d.ts +56 -0
- package/build/src/hierarchical_resolver.d.ts.map +1 -0
- package/build/src/hierarchical_resolver.js +87 -0
- package/build/src/hierarchical_resolver.js.map +1 -0
- package/build/src/identity.d.ts +155 -0
- package/build/src/identity.d.ts.map +1 -0
- package/build/src/identity.js +359 -0
- package/build/src/identity.js.map +1 -0
- package/build/src/manager.d.ts +225 -7
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1670 -23
- package/build/src/manager.js.map +1 -1
- package/build/src/memoize_ancestors.d.ts +23 -0
- package/build/src/memoize_ancestors.d.ts.map +1 -0
- package/build/src/memoize_ancestors.js +42 -0
- package/build/src/memoize_ancestors.js.map +1 -0
- package/build/src/middleware/app_access_middleware.d.ts +8 -6
- package/build/src/middleware/app_access_middleware.d.ts.map +1 -1
- package/build/src/middleware/app_access_middleware.js +10 -26
- package/build/src/middleware/app_access_middleware.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +5 -5
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +5 -5
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +11 -5
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_permission.js +4 -0
- package/build/src/models/authz_permission.js.map +1 -1
- package/build/src/models/authz_role.d.ts +13 -6
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role.js +6 -1
- package/build/src/models/authz_role.js.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +5 -5
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/openfga.d.ts +13 -0
- package/build/src/openfga.d.ts.map +1 -0
- package/build/src/openfga.js +12 -0
- package/build/src/openfga.js.map +1 -0
- package/build/src/sql_descendants.d.ts +51 -0
- package/build/src/sql_descendants.d.ts.map +1 -0
- package/build/src/sql_descendants.js +129 -0
- package/build/src/sql_descendants.js.map +1 -0
- package/build/src/testing/contract.d.ts +138 -7
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +2946 -24
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +4 -2
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/scope_tree.d.ts +65 -0
- package/build/src/testing/scope_tree.d.ts.map +1 -0
- package/build/src/testing/scope_tree.js +145 -0
- package/build/src/testing/scope_tree.js.map +1 -0
- package/build/src/traits/authz_scopes.d.ts +30 -6
- package/build/src/traits/authz_scopes.d.ts.map +1 -1
- package/build/src/traits/authz_scopes.js +30 -18
- package/build/src/traits/authz_scopes.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +5 -5
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +530 -28
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +10 -4
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/app_acl.stub +4 -2
- package/build/stubs/config/authorization.stub +66 -12
- package/build/stubs/migration.stub +57 -13
- package/package.json +11 -6
|
@@ -1,80 +1,139 @@
|
|
|
1
|
-
import { Exception } from '@adonisjs/core/exceptions';
|
|
2
1
|
import db from '@adonisjs/lucid/services/db';
|
|
3
|
-
import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, } from '@openfga/sdk';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
2
|
+
import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
|
|
3
|
+
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, PurgeIncompleteError, StoreNotEmptyError, UnknownPermissionError, } from '../errors.js';
|
|
4
|
+
import { APP_SCOPE_TYPE } from '../types.js';
|
|
5
|
+
import { APP_SCOPE_DB_UUID, assertRoleAssignableAt, declaredRoleAt, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
|
|
6
|
+
import { assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeKey, } from '../identity.js';
|
|
7
|
+
import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
|
|
8
|
+
import { assertKnownScope, canonicalScope, guardSql, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from './backend_guard.js';
|
|
9
|
+
import { CatalogCache, assertCatalogOptions, isRoleVisibleWith } from '../catalog_cache.js';
|
|
10
|
+
import { isClock, systemClock } from '../clock.js';
|
|
11
|
+
import { sqlExpiryCodec } from './sql_expiry.js';
|
|
12
|
+
/** Nombre de tipo admitido por FGA (`^[^:#@\s]{1,254}$`). */
|
|
13
|
+
const FGA_TYPE_FORMAT = /^[^:#@\s]{1,254}$/;
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* El driver `database` es inmune por construcción (guarda tipo y uuid en
|
|
21
|
-
* columnas separadas, sin codificar); esta validación protege la única ruta
|
|
22
|
-
* que serializa el scope a un string.
|
|
15
|
+
* `holderTypes` tiene que ser INYECTIVO. Si dos morph names caen en el mismo
|
|
16
|
+
* tipo FGA, para el store son un solo holder: un grant a `users:U` autoriza a
|
|
17
|
+
* `integrations:U`, `listSubjects` devuelve el morph equivocado y un revoke
|
|
18
|
+
* borra al otro (invariante 4, L0.2). El generador del modelo lo "sabía"
|
|
19
|
+
* (deduplicaba con un Set) y publicaba sin quejarse: ahora lanza aquí, al
|
|
20
|
+
* construir el driver y al generar el modelo, antes de tocar nada.
|
|
23
21
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
export function assertHolderTypes(holderTypes) {
|
|
23
|
+
if (!holderTypes || typeof holderTypes !== 'object' || Object.keys(holderTypes).length === 0) {
|
|
24
|
+
throw new AuthorizationConfigError('holderTypes vacío: el driver openfga necesita al menos un holder (morph name → tipo FGA)');
|
|
25
|
+
}
|
|
26
|
+
const morphsByFgaType = new Map();
|
|
27
|
+
for (const [morph, fgaType] of Object.entries(holderTypes)) {
|
|
28
|
+
if (typeof fgaType !== 'string' || !FGA_TYPE_FORMAT.test(fgaType)) {
|
|
29
|
+
throw new AuthorizationConfigError(`holderTypes['${morph}'] = ${JSON.stringify(fgaType)} no es un tipo FGA válido ` +
|
|
30
|
+
`(1-254 caracteres, sin ':', '#', '@' ni espacios)`);
|
|
31
|
+
}
|
|
32
|
+
morphsByFgaType.set(fgaType, [...(morphsByFgaType.get(fgaType) ?? []), morph]);
|
|
33
|
+
}
|
|
34
|
+
const collisions = [...morphsByFgaType.entries()].filter(([, morphs]) => morphs.length > 1);
|
|
35
|
+
if (collisions.length) {
|
|
36
|
+
throw new AuthorizationConfigError(`holderTypes no es inyectivo: ` +
|
|
37
|
+
collisions.map(([fga, morphs]) => `${morphs.join(' y ')} → '${fga}'`).join('; ') +
|
|
38
|
+
`. Dos holders con el mismo tipo FGA serían uno solo para el store.`);
|
|
29
39
|
}
|
|
30
40
|
}
|
|
41
|
+
// La clave de scope del id del binding (`app` | `<tipo>|<uuid>`) es la
|
|
42
|
+
// misma `scopeKey` del paquete (`identity.ts`; desde 3B también el owner de
|
|
43
|
+
// un rol local): `|` es el separador y `assertScope` impide que un
|
|
44
|
+
// componente lo lleve (dos scopes distintos no producen la misma clave) y
|
|
45
|
+
// rechaza `{app, uuid}` (L0.10).
|
|
31
46
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
47
|
+
* Id de binding (`<scopeKey>|<uuid>`: `app|<uuid>` o `<tipo>|<uuidScope>|<uuid>`)
|
|
48
|
+
* → scope + uuid del catálogo (del rol en `role_binding`, del permiso en
|
|
49
|
+
* `deny_binding`). Se parsea DESDE LA DERECHA (3A · A1): el último componente
|
|
50
|
+
* es el uuid y el resto la clave del scope, que tiene 1 parte (`app`) o 2
|
|
51
|
+
* (`<tipo>|<uuid>`). Antes el último componente era el slug codificado
|
|
52
|
+
* (`docs~read`) y el parseo contaba partes: ambiguo en cuanto la clave del
|
|
53
|
+
* scope variara de longitud (panel 2026-08-28, §2-C), y con un escape no
|
|
54
|
+
* inyectivo desde el llamante (L0.8a).
|
|
55
|
+
*
|
|
56
|
+
* `null` si no tiene la forma del motor O si alguna parte no pasa su
|
|
57
|
+
* gramática —el scope, la de identidad; el uuid, la de UUID canónico del
|
|
58
|
+
* catálogo—: un id que el driver no escribiría no es un hecho del motor
|
|
59
|
+
* aunque esté en el store. Los ids de 1.x/2.0–2.1 (con slug) caen aquí:
|
|
60
|
+
* 2.2 no los lee, y `openfga:import --reconcile` los cuenta como `extra`.
|
|
61
|
+
* Exportada para probarla sin servidor.
|
|
35
62
|
*/
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (parts.length === 2 && parts[0] === APP_SCOPE_TYPE) {
|
|
46
|
-
return { scope: { type: APP_SCOPE_TYPE, uuid: null }, slug: decodeSlug(parts[1]) };
|
|
63
|
+
export function parseBindingId(id) {
|
|
64
|
+
const cut = id.lastIndexOf('|');
|
|
65
|
+
if (cut < 0)
|
|
66
|
+
return null;
|
|
67
|
+
const uuid = id.slice(cut + 1);
|
|
68
|
+
const keyParts = id.slice(0, cut).split('|');
|
|
69
|
+
let scope;
|
|
70
|
+
if (keyParts.length === 1 && keyParts[0] === APP_SCOPE_TYPE) {
|
|
71
|
+
scope = { type: APP_SCOPE_TYPE, uuid: null };
|
|
47
72
|
}
|
|
48
|
-
if (
|
|
49
|
-
|
|
73
|
+
else if (keyParts.length === 2) {
|
|
74
|
+
scope = { type: keyParts[0], uuid: keyParts[1] };
|
|
50
75
|
}
|
|
51
|
-
|
|
76
|
+
else {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (!isValidScope(scope) || !isCatalogUuid(uuid))
|
|
80
|
+
return null;
|
|
81
|
+
return { scope, uuid };
|
|
52
82
|
}
|
|
53
83
|
/** `<tipoFga>:<uuid>` a partir del morph name del holder. */
|
|
54
84
|
function fgaSubjectWith(subject, holderTypes) {
|
|
55
85
|
const fgaType = holderTypes[subject.type];
|
|
56
86
|
if (!fgaType) {
|
|
57
|
-
|
|
87
|
+
// Contradicción de config (D15): el modelo del store no tiene ese tipo.
|
|
88
|
+
throw new AuthorizationConfigError(`Holder type '${subject.type}' no está en el modelo FGA ` +
|
|
58
89
|
`(declarados: ${Object.keys(holderTypes).join(', ') || 'ninguno'}). ` +
|
|
59
|
-
`Añádelo a holderTypes y regenera el authorization model
|
|
90
|
+
`Añádelo a holderTypes y regenera el authorization model.`);
|
|
60
91
|
}
|
|
61
92
|
return `${fgaType}:${subject.uuid}`;
|
|
62
93
|
}
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
/**
|
|
95
|
+
* El `context` de TODA consulta que evalúe relaciones: checks de roles y de
|
|
96
|
+
* denies. Un único constructor a propósito (S17): en cuanto una tupla del
|
|
97
|
+
* camino lleva la condición `not_expired`, un check sin `current_time` falla
|
|
98
|
+
* entero (400 → 503). Hoy los denies no llevan condición; el modo facts (3b)
|
|
99
|
+
* evalúa deny y grant en un solo check, así que no hay margen. Las
|
|
100
|
+
* enumeraciones no evalúan nada (`Read` devuelve tuplas escritas): filtran
|
|
101
|
+
* la caducidad en cliente con el MISMO reloj del driver (`now()`, J1): el
|
|
102
|
+
* `current_time` que viaja en cada check es el instante que decide.
|
|
103
|
+
*/
|
|
104
|
+
function checkContext(now) {
|
|
105
|
+
return { current_time: now.toISOString() };
|
|
65
106
|
}
|
|
66
107
|
/**
|
|
67
|
-
*
|
|
68
|
-
* no
|
|
69
|
-
*
|
|
108
|
+
* Alinea los resultados de un batchCheck con los checks pedidos por
|
|
109
|
+
* `correlationId`, no por posición (L0.14). El SDK reparte el lote en
|
|
110
|
+
* sub-lotes paralelos y concatena las respuestas según llegan: el orden no es
|
|
111
|
+
* el de los checks. Cardinalidad igual no basta —un id duplicado y otro
|
|
112
|
+
* ausente pasan el conteo—: cada check debe tener EXACTAMENTE un resultado y
|
|
113
|
+
* ningún resultado puede ser de un check que no se pidió.
|
|
70
114
|
*/
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
115
|
+
export function correlateBatchResults(checks, results) {
|
|
116
|
+
const byId = new Map();
|
|
117
|
+
for (const result of results) {
|
|
118
|
+
const id = result.correlationId;
|
|
119
|
+
if (byId.has(id)) {
|
|
120
|
+
throw new AuthorizationInternalError(`OpenFGA batchCheck devolvió dos resultados para el correlationId '${id}'`);
|
|
121
|
+
}
|
|
122
|
+
byId.set(id, result);
|
|
123
|
+
}
|
|
124
|
+
const aligned = checks.map((check) => {
|
|
125
|
+
const result = byId.get(check.correlationId);
|
|
126
|
+
if (!result) {
|
|
127
|
+
throw new AuthorizationInternalError(`OpenFGA batchCheck no devolvió resultado para el check '${check.correlationId}' (${check.relation} ${check.object})`);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
});
|
|
131
|
+
if (byId.size !== checks.length) {
|
|
132
|
+
const requested = new Set(checks.map((c) => c.correlationId));
|
|
133
|
+
const foreign = [...byId.keys()].filter((id) => !requested.has(id));
|
|
134
|
+
throw new AuthorizationInternalError(`OpenFGA batchCheck devolvió resultados de checks no pedidos: ${foreign.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
return aligned;
|
|
78
137
|
}
|
|
79
138
|
/**
|
|
80
139
|
* El authorization model en formato JSON del API de FGA, generado a partir
|
|
@@ -82,7 +141,8 @@ function sameExpiry(storedValidUntil, requested) {
|
|
|
82
141
|
* driver: si difieren, los checks no encuentran las tuplas.
|
|
83
142
|
*/
|
|
84
143
|
export function openFgaAuthorizationModel(holderTypeMap) {
|
|
85
|
-
|
|
144
|
+
assertHolderTypes(holderTypeMap);
|
|
145
|
+
const holderTypes = Object.values(holderTypeMap);
|
|
86
146
|
const direct = holderTypes.map((type) => ({ type }));
|
|
87
147
|
const directWithExpiry = [
|
|
88
148
|
...direct,
|
|
@@ -132,6 +192,142 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
|
|
|
132
192
|
const model = await scoped.writeAuthorizationModel(openFgaAuthorizationModel(holderTypeMap));
|
|
133
193
|
return { storeId: store.id, modelId: model.authorization_model_id };
|
|
134
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* ¿El error (o su cadena de causas) es el rechazo de FGA a escribir una tuple
|
|
197
|
+
* key que ya existe? Es la ÚNICA señal de carrera check-then-write que
|
|
198
|
+
* `grant` acepta: un 400 de validación (`validation_error`) o un 5xx no son
|
|
199
|
+
* "alguien escribió antes" y se propagan clasificados, con el error del SDK
|
|
200
|
+
* como causa (D6). Verificado contra OpenFGA v1.19: el duplicado llega como
|
|
201
|
+
* HTTP 400 con `apiErrorCode: 'write_failed_due_to_invalid_input'` y el
|
|
202
|
+
* mensaje "cannot write a tuple which already exists"; un 409 se acepta por
|
|
203
|
+
* si una versión del servidor lo devuelve así.
|
|
204
|
+
*/
|
|
205
|
+
export function isDuplicateWrite(error) {
|
|
206
|
+
let current = error;
|
|
207
|
+
for (let depth = 0; current && depth < 6; depth++) {
|
|
208
|
+
if (current.statusCode === 409)
|
|
209
|
+
return true;
|
|
210
|
+
if (current.apiErrorCode === 'write_failed_due_to_invalid_input' &&
|
|
211
|
+
/already exists/i.test(String(current.apiErrorMessage ?? current.message ?? ''))) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
current = current.cause;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Receta que acompaña al 503 de un `grant` SIN `expiresAt` cuando no se pudo
|
|
220
|
+
* leer la caducidad vigente: preservar exige saber qué hay, y asumir
|
|
221
|
+
* "permanente" sería L0.4 en modo degradado. Se añade al mensaje del error
|
|
222
|
+
* ya clasificado (mismo tipo, mismo `code`, misma causa).
|
|
223
|
+
*/
|
|
224
|
+
function withPreserveRecipe(error) {
|
|
225
|
+
error.message +=
|
|
226
|
+
`. 'grant' sin 'expiresAt' necesita leer la caducidad vigente para preservarla y no se ` +
|
|
227
|
+
`escribe a ciegas: si la intención es "permanente", pasa { expiresAt: null }; si es temporal, una Date.`;
|
|
228
|
+
return error;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
|
|
232
|
+
* 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
|
|
233
|
+
* SDK, que acoplaría el call-site al backend que este paquete abstrae.
|
|
234
|
+
*
|
|
235
|
+
* Se envuelve el cliente entero, y no llamada por llamada, a propósito: con
|
|
236
|
+
* once puntos de invocación lo fácil es olvidar uno, y quien añada el número
|
|
237
|
+
* doce no tendría por qué saber que debe envolverlo. Así la garantía se
|
|
238
|
+
* cumple por construcción en vez de por disciplina.
|
|
239
|
+
*
|
|
240
|
+
* Solo lo usa el driver. `provisionOpenFgaStore` y el importador son
|
|
241
|
+
* herramientas explícitamente de OpenFGA —las invocas por su nombre—, así que
|
|
242
|
+
* ahí el error del SDK es la información más útil y no rompe ninguna
|
|
243
|
+
* abstracción.
|
|
244
|
+
*/
|
|
245
|
+
function guardBackendErrors(client, timeoutMs) {
|
|
246
|
+
return new Proxy(client, {
|
|
247
|
+
get(target, prop, receiver) {
|
|
248
|
+
const value = Reflect.get(target, prop, receiver);
|
|
249
|
+
if (typeof value !== 'function')
|
|
250
|
+
return value;
|
|
251
|
+
return (...args) => {
|
|
252
|
+
const operation = String(prop);
|
|
253
|
+
const fail = (cause) => isTimeoutLike(cause)
|
|
254
|
+
? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
|
|
255
|
+
: new AuthorizationBackendError('openfga', operation, cause);
|
|
256
|
+
try {
|
|
257
|
+
const result = value.apply(target, args);
|
|
258
|
+
if (!(result instanceof Promise))
|
|
259
|
+
return result;
|
|
260
|
+
// Deadline TOTAL por llamada (reintentos del SDK incluidos): el
|
|
261
|
+
// `timeout` de axios corta cada intento, pero el SDK reintenta los
|
|
262
|
+
// errores de red con backoff y sin esto el llamante esperaría la
|
|
263
|
+
// suma de todos. Un deadline es un deadline.
|
|
264
|
+
return withDeadline(result.catch((error) => {
|
|
265
|
+
throw fail(error);
|
|
266
|
+
}), timeoutMs, () => new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
throw fail(error);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
276
|
+
/** Tope de operaciones por `Write` en FGA (verificado: `exceeded_entity_limit` a partir de 100). */
|
|
277
|
+
const PURGE_BATCH_SIZE = 100;
|
|
278
|
+
/** Tamaño de página de `Read` (máximo del servidor). */
|
|
279
|
+
const READ_PAGE_SIZE = 100;
|
|
280
|
+
/**
|
|
281
|
+
* Cota de páginas de una enumeración (1.000.000 de tuplas a 100 por página).
|
|
282
|
+
* Un `continuation_token` que no avanza —un servidor roto, un proxy o una
|
|
283
|
+
* caché delante— era un bucle infinito que ningún deadline cortaba, porque
|
|
284
|
+
* el deadline es por llamada (D12, auditor H7).
|
|
285
|
+
*/
|
|
286
|
+
export const MAX_READ_PAGES = 10_000;
|
|
287
|
+
/**
|
|
288
|
+
* Recorre TODAS las páginas de un `Read` con la misma cota que el driver
|
|
289
|
+
* (D12): token repetido o más de `MAX_READ_PAGES` páginas ⇒ 500. Lo usa el
|
|
290
|
+
* importador, que trabaja con un cliente crudo (herramienta FGA).
|
|
291
|
+
*/
|
|
292
|
+
async function* readPages(client, filter) {
|
|
293
|
+
let continuationToken;
|
|
294
|
+
const seen = new Set();
|
|
295
|
+
let pages = 0;
|
|
296
|
+
do {
|
|
297
|
+
const response = await client.read(filter, {
|
|
298
|
+
pageSize: READ_PAGE_SIZE,
|
|
299
|
+
continuationToken,
|
|
300
|
+
consistency: ConsistencyPreference.HigherConsistency,
|
|
301
|
+
});
|
|
302
|
+
pages += 1;
|
|
303
|
+
for (const tuple of response.tuples ?? [])
|
|
304
|
+
yield tuple;
|
|
305
|
+
continuationToken = response.continuation_token || undefined;
|
|
306
|
+
if (continuationToken) {
|
|
307
|
+
if (seen.has(continuationToken)) {
|
|
308
|
+
throw new AuthorizationInternalError(`Read: el continuation_token se repite (página ${pages})`);
|
|
309
|
+
}
|
|
310
|
+
if (pages >= MAX_READ_PAGES) {
|
|
311
|
+
throw new AuthorizationInternalError(`Read: más de ${MAX_READ_PAGES} páginas sin agotar el continuation_token`);
|
|
312
|
+
}
|
|
313
|
+
seen.add(continuationToken);
|
|
314
|
+
}
|
|
315
|
+
} while (continuationToken);
|
|
316
|
+
}
|
|
317
|
+
/** Clave textual de una tupla, para comparar conjuntos. */
|
|
318
|
+
function tupleId(key) {
|
|
319
|
+
return `${key.user}#${key.relation}@${key.object}`;
|
|
320
|
+
}
|
|
321
|
+
function tupleOf(fact) {
|
|
322
|
+
return fact.expiresAt
|
|
323
|
+
? {
|
|
324
|
+
...fact.key,
|
|
325
|
+
condition: { name: 'not_expired', context: { valid_until: fact.expiresAt.toISOString() } },
|
|
326
|
+
}
|
|
327
|
+
: fact.key;
|
|
328
|
+
}
|
|
329
|
+
/** Tope de tuplas por `Write` transaccional de FGA. */
|
|
330
|
+
const IMPORT_BATCH_SIZE = 100;
|
|
135
331
|
/**
|
|
136
332
|
* Migración de hechos database → openfga: copia las asignaciones vigentes y
|
|
137
333
|
* los denies de las tablas `authz_*` como tuples del store FGA.
|
|
@@ -140,225 +336,528 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
|
|
|
140
336
|
* volver a AUTHZ_DRIVER=database (solo se pierde lo escrito mientras se
|
|
141
337
|
* operó con openfga). El catálogo y la jerarquía nunca migran: son
|
|
142
338
|
* metadata local para ambos drivers.
|
|
143
|
-
* - Idempotente: re-ejecutar no duplica (onDuplicateWrites: Ignore).
|
|
144
339
|
* - Las asignaciones ya expiradas se saltan (no tiene sentido copiarlas);
|
|
145
340
|
* las de expiración futura viajan con la condition `not_expired`.
|
|
341
|
+
* - NUNCA `onDuplicateWrites: Ignore` (S7): en FGA la condición no es parte
|
|
342
|
+
* de la clave, así que "ignorar el duplicado" dejaba la caducidad vieja y
|
|
343
|
+
* reportaba éxito. Un store con tuplas exige `reconcile`, que compara
|
|
344
|
+
* tupla a tupla, reescribe las que difieren y cuenta las que SQL no tiene
|
|
345
|
+
* (`extra`); con `prune` las borra (`deleted`) y el reconcile converge
|
|
346
|
+
* (D14). Nunca silencioso: el reporte distingue written / updated /
|
|
347
|
+
* unchanged / extra / deleted / skippedExpired.
|
|
348
|
+
*
|
|
349
|
+
* Herramienta explícitamente de OpenFGA: los errores del SDK salen crudos.
|
|
146
350
|
*/
|
|
147
351
|
export async function importAuthzFactsToOpenFga(options) {
|
|
352
|
+
assertHolderTypes(options.holderTypes);
|
|
353
|
+
if (options.prune && !options.reconcile) {
|
|
354
|
+
throw new AuthorizationConfigError('openfga:import: `prune` solo tiene sentido con `reconcile` (es lo que sobra respecto a SQL lo que se borra)');
|
|
355
|
+
}
|
|
148
356
|
const client = new OpenFgaClient({
|
|
149
357
|
apiUrl: options.apiUrl,
|
|
150
358
|
storeId: options.storeId,
|
|
151
359
|
authorizationModelId: options.modelId,
|
|
152
360
|
});
|
|
153
|
-
const now =
|
|
361
|
+
const now = (options.now ?? systemClock)();
|
|
154
362
|
const result = {
|
|
155
|
-
|
|
156
|
-
|
|
363
|
+
written: 0,
|
|
364
|
+
updated: 0,
|
|
365
|
+
unchanged: 0,
|
|
366
|
+
extra: 0,
|
|
367
|
+
deleted: 0,
|
|
157
368
|
skippedExpired: 0,
|
|
158
369
|
dryRun: options.dryRun ?? false,
|
|
159
370
|
};
|
|
160
|
-
const tuples = [];
|
|
161
|
-
const assignments = await db
|
|
162
|
-
.from('authz_assignments as a')
|
|
163
|
-
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
164
|
-
.select('a.holder_type', 'a.holder_uuid', 'a.scope_type', 'a.scope_uuid', 'a.expires_at')
|
|
165
|
-
.select('r.slug as role_slug');
|
|
166
371
|
const rowScope = (row) => ({
|
|
167
372
|
type: row.scope_type,
|
|
168
373
|
uuid: row.scope_uuid === APP_SCOPE_DB_UUID ? null : row.scope_uuid,
|
|
169
374
|
});
|
|
375
|
+
const facts = [];
|
|
376
|
+
// `expires_at` se lee con el codec del motor (2.5-B · K2): en MySQL como
|
|
377
|
+
// cadena UTC vía DATE_FORMAT, sin depender de la zona del cliente.
|
|
378
|
+
const expiry = sqlExpiryCodec(db.connection());
|
|
379
|
+
const assignments = await db
|
|
380
|
+
.from('authz_assignments as a')
|
|
381
|
+
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
382
|
+
.select('a.holder_type', 'a.holder_uuid', 'a.scope_type', 'a.scope_uuid', expiry.select('a.expires_at', 'expires_at'))
|
|
383
|
+
.select('r.uuid as role_uuid');
|
|
170
384
|
for (const row of assignments) {
|
|
171
|
-
const expiresAt =
|
|
385
|
+
const expiresAt = expiry.fromDb(row.expires_at);
|
|
172
386
|
if (expiresAt && expiresAt <= now) {
|
|
173
387
|
result.skippedExpired++;
|
|
174
388
|
continue;
|
|
175
389
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
...key,
|
|
185
|
-
condition: { name: 'not_expired', context: { valid_until: expiresAt.toISOString() } },
|
|
186
|
-
}
|
|
187
|
-
: key);
|
|
188
|
-
result.assignments++;
|
|
390
|
+
facts.push({
|
|
391
|
+
key: {
|
|
392
|
+
user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
|
|
393
|
+
relation: 'assignee',
|
|
394
|
+
object: `role_binding:${scopeKey(rowScope(row))}|${row.role_uuid}`,
|
|
395
|
+
},
|
|
396
|
+
expiresAt,
|
|
397
|
+
});
|
|
189
398
|
}
|
|
190
399
|
const denies = await db
|
|
191
400
|
.from('authz_denies as d')
|
|
192
401
|
.join('authz_permissions as p', 'p.uuid', 'd.permission_uuid')
|
|
193
402
|
.select('d.holder_type', 'd.holder_uuid', 'd.scope_type', 'd.scope_uuid')
|
|
194
|
-
.select('p.
|
|
403
|
+
.select('p.uuid as permission_uuid');
|
|
195
404
|
for (const row of denies) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
405
|
+
facts.push({
|
|
406
|
+
key: {
|
|
407
|
+
user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
|
|
408
|
+
relation: 'denied',
|
|
409
|
+
object: `deny_binding:${scopeKey(rowScope(row))}|${row.permission_uuid}`,
|
|
410
|
+
},
|
|
411
|
+
expiresAt: null,
|
|
201
412
|
});
|
|
202
|
-
result.denies++;
|
|
203
413
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
414
|
+
// ¿Store vacío? Un Read sin filtro devuelve cualquier tupla que haya.
|
|
415
|
+
const probe = await client.read({}, { pageSize: 1 });
|
|
416
|
+
const storeIsEmpty = (probe.tuples ?? []).length === 0;
|
|
417
|
+
if (!storeIsEmpty && !options.reconcile) {
|
|
418
|
+
throw new StoreNotEmptyError(`El store ${options.storeId} ya tiene tuplas. Importar encima sin comparar dejaría ` +
|
|
419
|
+
`caducidades viejas en pie: usa --reconcile (compara tupla a tupla) o un store nuevo.`);
|
|
420
|
+
}
|
|
421
|
+
const toWrite = [];
|
|
422
|
+
const toReplace = [];
|
|
423
|
+
const toDelete = [];
|
|
424
|
+
if (storeIsEmpty) {
|
|
425
|
+
toWrite.push(...facts);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
for (const fact of facts) {
|
|
429
|
+
const stored = await client.read(fact.key);
|
|
430
|
+
const tuple = stored.tuples?.[0];
|
|
431
|
+
if (!tuple) {
|
|
432
|
+
toWrite.push(fact);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const storedExpiry = toExpiryDate(tuple.key?.condition?.context?.valid_until);
|
|
436
|
+
if (sameInstant(storedExpiry, fact.expiresAt))
|
|
437
|
+
result.unchanged++;
|
|
438
|
+
else
|
|
439
|
+
toReplace.push(fact);
|
|
440
|
+
}
|
|
441
|
+
// Lo que el store tiene de MÁS (D14): se lee entero y se resta el
|
|
442
|
+
// conjunto de SQL. Solo los objetos del motor; nada más vive en este
|
|
443
|
+
// modelo, pero si algo hubiera no es asunto del importador.
|
|
444
|
+
const wanted = new Set(facts.map((f) => tupleId(f.key)));
|
|
445
|
+
for await (const tuple of readPages(client, {})) {
|
|
446
|
+
const k = tuple?.key;
|
|
447
|
+
if (!k?.user || !k?.relation || !k?.object)
|
|
448
|
+
continue;
|
|
449
|
+
if (!/^(role_binding|deny_binding):/.test(k.object))
|
|
450
|
+
continue;
|
|
451
|
+
const key = { user: k.user, relation: k.relation, object: k.object };
|
|
452
|
+
if (!wanted.has(tupleId(key)))
|
|
453
|
+
toDelete.push(key);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
result.written = toWrite.length;
|
|
457
|
+
result.updated = toReplace.length;
|
|
458
|
+
result.extra = toDelete.length;
|
|
459
|
+
result.deleted = options.prune ? toDelete.length : 0;
|
|
460
|
+
if (result.dryRun)
|
|
461
|
+
return result;
|
|
462
|
+
// Sin Ignore: en un store vacío un duplicado es un bug (dos filas de SQL
|
|
463
|
+
// con la misma clave, imposible por el unique) y debe verse.
|
|
464
|
+
for (let i = 0; i < toWrite.length; i += IMPORT_BATCH_SIZE) {
|
|
465
|
+
await client.writeTuples(toWrite.slice(i, i + IMPORT_BATCH_SIZE).map(tupleOf));
|
|
466
|
+
}
|
|
467
|
+
// delete + write no caben en una misma request para la misma clave.
|
|
468
|
+
for (let i = 0; i < toReplace.length; i += IMPORT_BATCH_SIZE) {
|
|
469
|
+
const batch = toReplace.slice(i, i + IMPORT_BATCH_SIZE);
|
|
470
|
+
await client.deleteTuples(batch.map((f) => f.key));
|
|
471
|
+
await client.writeTuples(batch.map(tupleOf));
|
|
472
|
+
}
|
|
473
|
+
if (options.prune) {
|
|
474
|
+
for (let i = 0; i < toDelete.length; i += IMPORT_BATCH_SIZE) {
|
|
475
|
+
await client.deleteTuples(toDelete.slice(i, i + IMPORT_BATCH_SIZE));
|
|
210
476
|
}
|
|
211
477
|
}
|
|
212
478
|
return result;
|
|
213
479
|
}
|
|
214
480
|
export class OpenFgaAuthorizationDriver {
|
|
215
481
|
client;
|
|
216
|
-
|
|
482
|
+
chainResolver;
|
|
217
483
|
holderTypes;
|
|
484
|
+
/**
|
|
485
|
+
* Contadores observables del driver. `unparseableBindings`: ids del store
|
|
486
|
+
* que el motor no entiende (L0.16). Cada uno es un hecho que las
|
|
487
|
+
* enumeraciones NO muestran; se registra y se cuenta, jamás un `continue`
|
|
488
|
+
* mudo — quien opera el store tiene que poder verlo.
|
|
489
|
+
*/
|
|
490
|
+
diagnostics = { unparseableBindings: 0 };
|
|
491
|
+
logger;
|
|
492
|
+
timeoutMs;
|
|
493
|
+
consistency;
|
|
494
|
+
/** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
|
|
495
|
+
now;
|
|
496
|
+
/**
|
|
497
|
+
* Memo del catálogo (2A): permisos, roles por nivel y roles que conceden
|
|
498
|
+
* cada permiso se leen de aquí en el camino caliente; antes eran dos
|
|
499
|
+
* consultas SQL por `authorize`. Los hechos siguen en FGA en cada pregunta.
|
|
500
|
+
* Se revalida contra `authz_catalog_version` (2D · F1): cada operación
|
|
501
|
+
* toma la foto UNA vez (`view()`) y lee de ella todo lo que necesita.
|
|
502
|
+
* `catalog.invalidate()` fuerza la recarga de ESTE memo.
|
|
503
|
+
*/
|
|
504
|
+
catalog;
|
|
218
505
|
constructor(options) {
|
|
219
|
-
|
|
506
|
+
assertHolderTypes(options.holderTypes);
|
|
507
|
+
assertCatalogOptions('OpenFgaAuthorizationDriver', options);
|
|
508
|
+
if (options.now !== undefined && !isClock(options.now)) {
|
|
509
|
+
throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
510
|
+
}
|
|
511
|
+
this.now = options.now ?? systemClock;
|
|
512
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
513
|
+
this.catalog =
|
|
514
|
+
options.catalog ?? new CatalogCache({ driver: 'openfga', timeoutMs, revalidate: options.catalogRevalidate });
|
|
515
|
+
this.client = guardBackendErrors(new OpenFgaClient({
|
|
220
516
|
apiUrl: options.apiUrl,
|
|
221
517
|
storeId: options.storeId,
|
|
222
518
|
authorizationModelId: options.modelId,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
519
|
+
// `baseOptions` se funde en la config de axios de cada request: es la
|
|
520
|
+
// única vía del SDK (no tiene `timeoutMs` propio; su default es 10 s).
|
|
521
|
+
baseOptions: { timeout: timeoutMs },
|
|
522
|
+
// Sin reintentos a escondidas (ver `OpenFgaDriverOptions.retryParams`).
|
|
523
|
+
retryParams: { maxRetry: 0, ...options.retryParams },
|
|
524
|
+
}), timeoutMs);
|
|
525
|
+
// Sin resolutor solo existe la raíz (L0.3: el default plano desapareció).
|
|
526
|
+
this.chainResolver = options.resolveChain ?? rootOnlyResolver;
|
|
227
527
|
this.holderTypes = options.holderTypes;
|
|
528
|
+
this.logger = options.logger ?? console;
|
|
529
|
+
this.timeoutMs = timeoutMs;
|
|
530
|
+
this.consistency =
|
|
531
|
+
options.consistency === 'minimize_latency'
|
|
532
|
+
? ConsistencyPreference.MinimizeLatency
|
|
533
|
+
: ConsistencyPreference.HigherConsistency;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* `[scope canónico, ...ancestros]`, o `null` si el scope no existe
|
|
537
|
+
* (lecturas: denegar). `chain[0]` —la fila del consumidor, no lo que
|
|
538
|
+
* escribió el llamante— es el scope que va en la clave de cada binding
|
|
539
|
+
* (2.5-B · K1): un alias del uuid que el árbol funde con la fila real
|
|
540
|
+
* llega aquí ya canónico y el `deny_binding` escrito canónico casa.
|
|
541
|
+
*/
|
|
542
|
+
chain(scope, operation) {
|
|
543
|
+
return resolveChain(this.chainResolver, scope, operation);
|
|
544
|
+
}
|
|
545
|
+
/** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
|
|
546
|
+
knownScope(scope, operation) {
|
|
547
|
+
return assertKnownScope(this.chainResolver, scope, operation);
|
|
548
|
+
}
|
|
549
|
+
/** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
|
|
550
|
+
canonicalOrSelf(scope, operation) {
|
|
551
|
+
return canonicalScope(this.chainResolver, scope, operation);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Vista de este driver con OTRO resolutor de ancestros y el mismo estado
|
|
555
|
+
* (cliente, memo del catálogo, deadline, diagnósticos). Es lo que usa
|
|
556
|
+
* `AuthorizationManager.forRequest()` para leer con un resolutor memoizado
|
|
557
|
+
* sin tocar el driver compartido: hereda por prototipo y solo sobrescribe
|
|
558
|
+
* el resolutor.
|
|
559
|
+
*/
|
|
560
|
+
withChainResolver(resolveChain) {
|
|
561
|
+
const view = Object.create(this);
|
|
562
|
+
view.chainResolver = resolveChain;
|
|
563
|
+
return view;
|
|
228
564
|
}
|
|
229
|
-
|
|
230
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Vista de este driver con OTRO reloj de pared (2.5 · J1): mismo cliente,
|
|
567
|
+
* store, memo y resolutor; solo cambia el `now` que viaja como
|
|
568
|
+
* `current_time` y filtra las enumeraciones. Lo aplica el manager con
|
|
569
|
+
* `config.clock` y el juez para fijar el instante.
|
|
570
|
+
*/
|
|
571
|
+
withClock(now) {
|
|
572
|
+
if (!isClock(now)) {
|
|
573
|
+
throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
|
|
574
|
+
}
|
|
575
|
+
const view = Object.create(this);
|
|
576
|
+
view.now = now;
|
|
577
|
+
return view;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* El `context` de los checks de UNA operación, con el reloj de ESTE driver
|
|
581
|
+
* (o vista): se construye una vez por operación y viaja en todos sus
|
|
582
|
+
* checks (2.5-B · K9). Antes se leía el reloj por check y un mismo
|
|
583
|
+
* `authorize` evaluaba el deny en un instante y el rol en otro.
|
|
584
|
+
*/
|
|
585
|
+
checkContext() {
|
|
586
|
+
return checkContext(this.now());
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Consulta al catálogo local clasificando su fallo. Con este driver el
|
|
590
|
+
* catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
|
|
591
|
+
* era un error crudo de Lucid que se presentaba como bug de aplicación (N3).
|
|
592
|
+
*/
|
|
593
|
+
sql(operation, fn) {
|
|
594
|
+
return guardSql('openfga', operation, this.timeoutMs, fn);
|
|
231
595
|
}
|
|
232
596
|
fgaSubject(subject) {
|
|
233
597
|
return fgaSubjectWith(subject, this.holderTypes);
|
|
234
598
|
}
|
|
235
599
|
/**
|
|
236
|
-
* batchCheck
|
|
237
|
-
*
|
|
600
|
+
* Un batchCheck con TODOS los checks (el SDK trocea a 50 por request y
|
|
601
|
+
* paraleliza), cada uno con un `correlationId` propio, y la respuesta
|
|
602
|
+
* alineada por ese id: un resultado por check, ni uno más ni uno menos.
|
|
238
603
|
*/
|
|
239
604
|
async batchCheckAll(checks) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
605
|
+
if (checks.length === 0)
|
|
606
|
+
return [];
|
|
607
|
+
const withIds = checks.map((check, index) => ({
|
|
608
|
+
...check,
|
|
609
|
+
correlationId: String(index),
|
|
610
|
+
}));
|
|
611
|
+
const response = await this.client.batchCheck({ checks: withIds }, { consistency: this.consistency });
|
|
612
|
+
const results = correlateBatchResults(withIds, response.result);
|
|
613
|
+
// Un 200 con `error` en un check individual (`input_error`,
|
|
614
|
+
// `internal_error`…) es una caída parcial del backend, no un "sin
|
|
615
|
+
// permiso": se clasifica igual que un 5xx (invariante 5, D1). Antes la
|
|
616
|
+
// fase de roles y `hasRole` lo colapsaban en `false`.
|
|
617
|
+
const failed = results.find((r) => r.error);
|
|
618
|
+
if (failed) {
|
|
619
|
+
throw new AuthorizationBackendError('openfga', `batchCheck (${failed.request?.relation} ${failed.request?.object})`, failed.error);
|
|
250
620
|
}
|
|
251
621
|
return results;
|
|
252
622
|
}
|
|
253
|
-
// ── Catálogo local (compartido entre drivers)
|
|
623
|
+
// ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
|
|
624
|
+
// Una carga por driver/proceso en vez de una o dos consultas SQL por
|
|
625
|
+
// pregunta, más una revalidación por operación (2D · F1). Un fallo de
|
|
626
|
+
// carga sale como 503, igual que antes.
|
|
254
627
|
async findPermission(slug) {
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
async findRoleOrFail(slug, scopeType) {
|
|
258
|
-
const role = await db
|
|
259
|
-
.from('authz_roles')
|
|
260
|
-
.where('slug', slug)
|
|
261
|
-
.where('scope_type', scopeType)
|
|
262
|
-
.select('uuid')
|
|
263
|
-
.first();
|
|
264
|
-
if (!role) {
|
|
265
|
-
throw new Exception(`Rol '${slug}' no existe en el catálogo para el nivel '${scopeType}'`, {
|
|
266
|
-
status: 422,
|
|
267
|
-
});
|
|
268
|
-
}
|
|
628
|
+
return (await this.catalog.view()).permission(slug);
|
|
269
629
|
}
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
return byScopeType;
|
|
630
|
+
/**
|
|
631
|
+
* Filtro por catálogo de las lecturas de membresía (`listRoles`,
|
|
632
|
+
* `listRoleScopes`, `rolesInChain`, `listScopes`): un binding cuyo uuid ya
|
|
633
|
+
* no está en `authz_roles` —o está, pero declarado para OTRO nivel, o es
|
|
634
|
+
* local a un scope que no está en la cadena del binding (3B · B2)— es una
|
|
635
|
+
* tupla huérfana, no una membresía; igual que en `database`, donde el
|
|
636
|
+
* catálogo lo excluye (D5). La tupla la recoge `authz:reconcile`. Desde 3A
|
|
637
|
+
* la resolución es por uuid (`roleByUuid`), nunca por slug. `chainKeys` es
|
|
638
|
+
* la cadena del scope del BINDING (desde él hacia la raíz).
|
|
639
|
+
*/
|
|
640
|
+
declaredRole(catalog, binding, chainKeys) {
|
|
641
|
+
return declaredRoleAt(catalog, binding.uuid, binding.scope.type, chainKeys);
|
|
284
642
|
}
|
|
285
643
|
// ── Contrato ──────────────────────────────────────────────────────────
|
|
644
|
+
/**
|
|
645
|
+
* Los checks de UNA pregunta (subject, permiso, cadena): los denies de cada
|
|
646
|
+
* nivel y los roles del catálogo que conceden el permiso en cada nivel.
|
|
647
|
+
* Sin rol que conceda ⇒ `null`: la respuesta es `false` digan lo que digan
|
|
648
|
+
* los denies y no se pregunta al backend (2A).
|
|
649
|
+
*/
|
|
650
|
+
checksFor(user, permissionUuid, chain, granting, context) {
|
|
651
|
+
// Por nivel, solo los roles que EXISTEN ahí (3B · B2): globales, o locales
|
|
652
|
+
// a un scope de la cadena desde ese nivel. Un rol de otro tenant no
|
|
653
|
+
// cuesta un check (mismo número de checks que sin roles locales).
|
|
654
|
+
const keysFrom = chainKeysFrom(chain);
|
|
655
|
+
const roles = chain.flatMap((s, i) => (granting.get(s.type) ?? [])
|
|
656
|
+
.filter((role) => isRoleVisibleWith(role, keysFrom[i]))
|
|
657
|
+
.map((role) => ({
|
|
658
|
+
user,
|
|
659
|
+
relation: 'assignee',
|
|
660
|
+
object: `role_binding:${scopeKey(s)}|${role.uuid}`,
|
|
661
|
+
context,
|
|
662
|
+
})));
|
|
663
|
+
if (roles.length === 0)
|
|
664
|
+
return null;
|
|
665
|
+
const denies = chain.map((s) => ({
|
|
666
|
+
user,
|
|
667
|
+
relation: 'denied',
|
|
668
|
+
object: `deny_binding:${scopeKey(s)}|${permissionUuid}`,
|
|
669
|
+
context,
|
|
670
|
+
}));
|
|
671
|
+
return { denies, roles };
|
|
672
|
+
}
|
|
286
673
|
async authorize(subject, permission, scope) {
|
|
287
|
-
|
|
674
|
+
assertIdentity({ subject, permission, scope });
|
|
675
|
+
// Una foto del catálogo por pregunta: permiso y roles que conceden salen
|
|
676
|
+
// de la misma versión (y se paga una sola revalidación).
|
|
677
|
+
const catalog = await this.catalog.view();
|
|
678
|
+
const perm = catalog.permission(permission);
|
|
288
679
|
if (!perm)
|
|
289
680
|
return false;
|
|
290
681
|
const user = this.fgaSubject(subject);
|
|
291
|
-
const chain = await this.chain(scope);
|
|
292
|
-
|
|
293
|
-
// trata como denegado (jamás se ignora un deny por un fallo puntual).
|
|
294
|
-
const denyChecks = chain.map((s) => ({
|
|
295
|
-
user,
|
|
296
|
-
relation: 'denied',
|
|
297
|
-
object: `deny_binding:${scopeKey(s)}|${encodeSlug(permission)}`,
|
|
298
|
-
}));
|
|
299
|
-
const denyResults = await this.batchCheckAll(denyChecks);
|
|
300
|
-
if (denyResults.some((r) => r.allowed || r.error))
|
|
682
|
+
const chain = await this.chain(scope, 'authorize');
|
|
683
|
+
if (!chain)
|
|
301
684
|
return false;
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
user,
|
|
307
|
-
relation: 'assignee',
|
|
308
|
-
object: `role_binding:${scopeKey(s)}|${encodeSlug(roleSlug)}`,
|
|
309
|
-
context: checkContext(),
|
|
310
|
-
})));
|
|
311
|
-
if (checks.length === 0)
|
|
685
|
+
// Si ningún rol de la cadena concede el permiso, la respuesta es `false`
|
|
686
|
+
// digan lo que digan los denies: no se pregunta al backend.
|
|
687
|
+
const checks = this.checksFor(user, perm.uuid, chain, catalog.rolesGranting(perm.uuid), this.checkContext());
|
|
688
|
+
if (!checks)
|
|
312
689
|
return false;
|
|
313
|
-
|
|
314
|
-
|
|
690
|
+
// UN solo batchCheck (2A): los denies de la cadena y los roles que
|
|
691
|
+
// conceden van en la misma request; el SDK trocea a 50 y paraleliza.
|
|
692
|
+
// Regla, en este orden: cualquier `error` ⇒ 503 (D1, dentro de
|
|
693
|
+
// `batchCheckAll`, antes de mirar nada); algún deny `allowed` ⇒ false;
|
|
694
|
+
// algún rol `allowed` ⇒ true. Antes eran dos requests secuenciales
|
|
695
|
+
// (denies, luego roles) con la misma regla.
|
|
696
|
+
const results = await this.batchCheckAll([...checks.denies, ...checks.roles]);
|
|
697
|
+
if (results.slice(0, checks.denies.length).some((r) => r.allowed))
|
|
698
|
+
return false;
|
|
699
|
+
return results.slice(checks.denies.length).some((r) => r.allowed);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* `authorize` sobre N scopes con UN batchCheck (2.1, B6): los checks de
|
|
703
|
+
* todas las cadenas viajan juntos (el SDK trocea a 50 y paraleliza) y se
|
|
704
|
+
* atribuyen a su scope por posición dentro del lote correlacionado
|
|
705
|
+
* (L0.14). Misma regla que `authorize`, por scope: `error` en cualquier
|
|
706
|
+
* check ⇒ 503 entero (D1); deny `allowed` ⇒ false; rol `allowed` ⇒ true.
|
|
707
|
+
* Scope desconocido o sin rol que conceda ⇒ false sin checks.
|
|
708
|
+
*/
|
|
709
|
+
async authorizeMany(subject, permission, scopes) {
|
|
710
|
+
assertIdentity({ subject, permission });
|
|
711
|
+
for (const scope of scopes)
|
|
712
|
+
assertIdentity({ scope });
|
|
713
|
+
if (scopes.length === 0)
|
|
714
|
+
return [];
|
|
715
|
+
const catalog = await this.catalog.view();
|
|
716
|
+
const perm = catalog.permission(permission);
|
|
717
|
+
if (!perm)
|
|
718
|
+
return scopes.map(() => false);
|
|
719
|
+
const user = this.fgaSubject(subject);
|
|
720
|
+
const granting = catalog.rolesGranting(perm.uuid);
|
|
721
|
+
// Un instante para todo el lote (K9): N scopes, una pregunta.
|
|
722
|
+
const context = this.checkContext();
|
|
723
|
+
const batch = [];
|
|
724
|
+
/** Por posición: `null` = false sin preguntar; si no, [inicio, nºDenies, nºRoles] dentro del lote. */
|
|
725
|
+
const slots = [];
|
|
726
|
+
// Un scope repetido comparte slot (y cadena) con su primera aparición
|
|
727
|
+
// (G2, CR9): mismos checks, misma respuesta por posición, sin duplicar
|
|
728
|
+
// el lote.
|
|
729
|
+
const slotByScope = new Map();
|
|
730
|
+
for (const scope of scopes) {
|
|
731
|
+
const scopeId = scopeKey(scope);
|
|
732
|
+
if (slotByScope.has(scopeId)) {
|
|
733
|
+
slots.push(slotByScope.get(scopeId));
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const chain = await this.chain(scope, 'authorizeMany');
|
|
737
|
+
const checks = chain ? this.checksFor(user, perm.uuid, chain, granting, context) : null;
|
|
738
|
+
const slot = checks ? [batch.length, checks.denies.length, checks.roles.length] : null;
|
|
739
|
+
if (checks)
|
|
740
|
+
batch.push(...checks.denies, ...checks.roles);
|
|
741
|
+
slotByScope.set(scopeId, slot);
|
|
742
|
+
slots.push(slot);
|
|
743
|
+
}
|
|
744
|
+
const results = await this.batchCheckAll(batch);
|
|
745
|
+
return slots.map((slot) => {
|
|
746
|
+
if (!slot)
|
|
747
|
+
return false;
|
|
748
|
+
const [start, denies, roles] = slot;
|
|
749
|
+
if (results.slice(start, start + denies).some((r) => r.allowed))
|
|
750
|
+
return false;
|
|
751
|
+
return results.slice(start + denies, start + denies + roles).some((r) => r.allowed);
|
|
752
|
+
});
|
|
315
753
|
}
|
|
316
754
|
async grant(subject, role, scope, options = {}) {
|
|
317
|
-
|
|
755
|
+
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
756
|
+
// El binding lleva la identidad canónica del árbol (K1), nunca la forma
|
|
757
|
+
// del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
|
|
758
|
+
// que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
|
|
759
|
+
// con una composición legal (B5).
|
|
760
|
+
const chain = await this.knownScope(scope, 'grant');
|
|
761
|
+
const [target] = chain;
|
|
762
|
+
const catalog = await this.catalog.view();
|
|
763
|
+
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
764
|
+
assertRoleAssignableAt(catalog, declared);
|
|
765
|
+
const roleUuid = declared.uuid;
|
|
318
766
|
const key = {
|
|
319
767
|
user: this.fgaSubject(subject),
|
|
320
768
|
relation: 'assignee',
|
|
321
|
-
object: `role_binding:${scopeKey(
|
|
769
|
+
object: `role_binding:${scopeKey(target)}|${roleUuid}`,
|
|
322
770
|
};
|
|
323
|
-
const
|
|
771
|
+
const tupleFor = (expiresAt) => expiresAt
|
|
324
772
|
? {
|
|
325
773
|
...key,
|
|
326
774
|
condition: {
|
|
327
775
|
name: 'not_expired',
|
|
328
|
-
context: { valid_until:
|
|
776
|
+
context: { valid_until: expiresAt.toISOString() },
|
|
329
777
|
},
|
|
330
778
|
}
|
|
331
779
|
: key;
|
|
332
780
|
// FGA no admite delete+write de la misma tuple key en una transacción, así
|
|
333
|
-
// que
|
|
781
|
+
// que cambiar la expiración obliga a dos llamadas — y entre ellas hay un
|
|
334
782
|
// instante en el que authorize() responde false.
|
|
335
783
|
//
|
|
336
784
|
// Se mira primero qué hay, para NO pagar esa ventana cuando no hace falta:
|
|
337
785
|
// - si no existe la tuple → solo write (el caso del primer grant);
|
|
338
|
-
// - si existe
|
|
339
|
-
// - solo si la
|
|
340
|
-
//
|
|
786
|
+
// - si existe con la caducidad que toca → no-op (un seeder no toca nada);
|
|
787
|
+
// - solo si la caducidad CAMBIA de verdad se hace delete+write.
|
|
788
|
+
// Y la lectura es lo que hace posible "omitido = preservar" (L0.4).
|
|
341
789
|
const current = await this.readAssignment(key);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
790
|
+
if (current.kind === 'unknown') {
|
|
791
|
+
// Sin lectura no hay forma de preservar una caducidad vigente: asumir
|
|
792
|
+
// "permanente" sería exactamente el defecto en modo degradado. Con un
|
|
793
|
+
// objetivo explícito (`Date`/`null`) sí se puede escribir a ciegas.
|
|
794
|
+
if (options.expiresAt === undefined)
|
|
795
|
+
throw withPreserveRecipe(current.error);
|
|
796
|
+
const expiresAt = options.expiresAt;
|
|
797
|
+
const existed = await this.writeAssignment(key, tupleFor(expiresAt));
|
|
798
|
+
return { existed, expiresAt };
|
|
799
|
+
}
|
|
800
|
+
if (current.kind === 'present') {
|
|
801
|
+
const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
|
|
802
|
+
if (sameInstant(current.validUntil, expiresAt)) {
|
|
803
|
+
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
804
|
+
}
|
|
805
|
+
await this.replaceAssignment(key, tupleFor(expiresAt));
|
|
806
|
+
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
807
|
+
}
|
|
808
|
+
// No había nada: un write basta y no hay ventana de denegación. Si entre
|
|
809
|
+
// el read y el write otro proceso escribió la misma key, este write
|
|
810
|
+
// choca con un 409 — entonces se relee y se aplica el re-grant sobre lo
|
|
811
|
+
// que quedó, para que gane el último escritor y no se pierda esta
|
|
812
|
+
// caducidad. Cualquier otro fallo del write no es una carrera (D6).
|
|
813
|
+
const expiresAt = options.expiresAt ?? null;
|
|
814
|
+
try {
|
|
815
|
+
await this.client.writeTuples([tupleFor(expiresAt)]);
|
|
816
|
+
return { existed: false, expiresAt };
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
if (!isDuplicateWrite(error))
|
|
820
|
+
throw error;
|
|
821
|
+
const raced = await this.readAssignment(key);
|
|
822
|
+
if (raced.kind === 'present') {
|
|
823
|
+
const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
|
|
824
|
+
if (!sameInstant(raced.validUntil, target))
|
|
825
|
+
await this.replaceAssignment(key, tupleFor(target));
|
|
826
|
+
return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
|
|
353
827
|
}
|
|
354
|
-
|
|
355
|
-
//
|
|
828
|
+
if (options.expiresAt === undefined) {
|
|
829
|
+
// El write chocó y la relectura no ve la tupla (o falló): sin objetivo
|
|
830
|
+
// explícito no se sabe qué preservar. El 409 original va como causa.
|
|
831
|
+
throw withPreserveRecipe(raced.kind === 'unknown'
|
|
832
|
+
? raced.error
|
|
833
|
+
: new AuthorizationBackendError('openfga', 'grant (el write chocó y la relectura no ve la tupla)', error));
|
|
356
834
|
}
|
|
835
|
+
const existed = await this.writeAssignment(key, tupleFor(options.expiresAt));
|
|
836
|
+
return { existed, expiresAt: options.expiresAt };
|
|
357
837
|
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Write directo; si la key ya existía (409), camino largo. Devuelve si
|
|
841
|
+
* existía. Cualquier otro fallo se propaga tal cual (ya clasificado).
|
|
842
|
+
*/
|
|
843
|
+
async writeAssignment(key, tuple) {
|
|
844
|
+
try {
|
|
845
|
+
await this.client.writeTuples([tuple]);
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
if (!isDuplicateWrite(error))
|
|
850
|
+
throw error;
|
|
851
|
+
await this.replaceAssignment(key, tuple);
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/** delete + write (dos llamadas: FGA no admite ambas sobre la misma key en una). */
|
|
856
|
+
async replaceAssignment(key, tuple) {
|
|
358
857
|
await this.client.deleteTuples([key], {
|
|
359
858
|
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
360
859
|
});
|
|
361
|
-
await this.client.writeTuples([
|
|
860
|
+
await this.client.writeTuples([tuple], {
|
|
362
861
|
conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
|
|
363
862
|
});
|
|
364
863
|
}
|
|
@@ -367,144 +866,433 @@ export class OpenFgaAuthorizationDriver {
|
|
|
367
866
|
*
|
|
368
867
|
* Distinguir `unknown` de `present` sin condición es lo que impide un bug
|
|
369
868
|
* feo: si un fallo de lectura se pareciera a "existe y sin expiración", un
|
|
370
|
-
* grant sin expiración saldría por el atajo del no-op y se perdería
|
|
371
|
-
*
|
|
869
|
+
* grant sin expiración saldría por el atajo del no-op y se perdería. El
|
|
870
|
+
* error de lectura (ya clasificado como 503) viaja con el resultado para
|
|
871
|
+
* que quien no pueda seguir sin él lo propague.
|
|
372
872
|
*/
|
|
373
873
|
async readAssignment(key) {
|
|
374
874
|
try {
|
|
375
|
-
const response = await this.client.read(key);
|
|
875
|
+
const response = await this.client.read(key, { consistency: this.consistency });
|
|
376
876
|
const tuple = response.tuples?.[0];
|
|
377
877
|
if (!tuple)
|
|
378
878
|
return { kind: 'absent' };
|
|
379
879
|
const validUntil = tuple.key?.condition?.context?.valid_until;
|
|
380
|
-
return { kind: 'present', validUntil:
|
|
880
|
+
return { kind: 'present', validUntil: toExpiryDate(validUntil) };
|
|
381
881
|
}
|
|
382
|
-
catch {
|
|
383
|
-
|
|
384
|
-
// funciona exista o no la tuple.
|
|
385
|
-
return { kind: 'unknown' };
|
|
882
|
+
catch (error) {
|
|
883
|
+
return { kind: 'unknown', error };
|
|
386
884
|
}
|
|
387
885
|
}
|
|
388
886
|
async revoke(subject, role, scope) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
887
|
+
assertIdentity({ subject, role, scope });
|
|
888
|
+
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
|
|
889
|
+
// quitan los bindings de TODOS los roles con ese nombre en el scope
|
|
890
|
+
// exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
|
|
891
|
+
const named = rolesToRevoke(await this.catalog.view(), role, scope);
|
|
892
|
+
const target = await this.canonicalOrSelf(scope, 'revoke');
|
|
893
|
+
const user = this.fgaSubject(subject);
|
|
894
|
+
await this.client.deleteTuples(named.map((r) => ({ user, relation: 'assignee', object: `role_binding:${scopeKey(target)}|${r.uuid}` })), { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
|
|
396
895
|
}
|
|
397
896
|
async hasRole(subject, role, scope) {
|
|
897
|
+
assertIdentity({ subject, role, scope });
|
|
398
898
|
const user = this.fgaSubject(subject);
|
|
399
|
-
const chain = await this.chain(scope);
|
|
400
|
-
|
|
899
|
+
const chain = await this.chain(scope, 'hasRole');
|
|
900
|
+
if (!chain)
|
|
901
|
+
return false;
|
|
902
|
+
// El id del binding lleva el scope (y con él su tipo) y el UUID del rol
|
|
903
|
+
// que el catálogo declara con ese slug para el tipo de ESE nivel (3A):
|
|
904
|
+
// en cada nivel solo casa el rol de ese nivel. Con `{ slug, scopeType }`
|
|
905
|
+
// se recorta la cadena a los niveles de ese tipo (L0.6). Y solo se
|
|
906
|
+
// pregunta por los niveles para los que el catálogo declara el rol (D5):
|
|
907
|
+
// un rol retirado no es membresía aunque su tupla siga en el store.
|
|
908
|
+
const catalog = await this.catalog.view();
|
|
909
|
+
const targets = hasRoleTargets(catalog, role, chain);
|
|
910
|
+
if (targets.length === 0)
|
|
911
|
+
return false;
|
|
912
|
+
const context = this.checkContext();
|
|
913
|
+
const results = await this.batchCheckAll(targets.map(({ scope: s, roleUuid }) => ({
|
|
401
914
|
user,
|
|
402
915
|
relation: 'assignee',
|
|
403
|
-
object: `role_binding:${scopeKey(s)}|${
|
|
404
|
-
context
|
|
916
|
+
object: `role_binding:${scopeKey(s)}|${roleUuid}`,
|
|
917
|
+
context,
|
|
405
918
|
})));
|
|
406
|
-
return results.some((r) => r.allowed
|
|
919
|
+
return results.some((r) => r.allowed);
|
|
407
920
|
}
|
|
408
921
|
async deny(subject, permission, scope) {
|
|
922
|
+
assertIdentity({ subject, permission, scope });
|
|
409
923
|
const perm = await this.findPermission(permission);
|
|
410
|
-
if (!perm)
|
|
411
|
-
throw new
|
|
412
|
-
|
|
924
|
+
if (!perm)
|
|
925
|
+
throw new UnknownPermissionError(permission);
|
|
926
|
+
const [target] = await this.knownScope(scope, 'deny');
|
|
413
927
|
await this.client.writeTuples([
|
|
414
928
|
{
|
|
415
929
|
user: this.fgaSubject(subject),
|
|
416
930
|
relation: 'denied',
|
|
417
|
-
object: `deny_binding:${scopeKey(
|
|
931
|
+
object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
|
|
418
932
|
},
|
|
419
933
|
], { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } });
|
|
420
934
|
}
|
|
421
935
|
async removeDeny(subject, permission, scope) {
|
|
936
|
+
assertIdentity({ subject, permission, scope });
|
|
937
|
+
const perm = await this.findPermission(permission);
|
|
938
|
+
if (!perm)
|
|
939
|
+
throw new UnknownPermissionError(permission);
|
|
940
|
+
const target = await this.canonicalOrSelf(scope, 'removeDeny');
|
|
422
941
|
await this.client.deleteTuples([
|
|
423
942
|
{
|
|
424
943
|
user: this.fgaSubject(subject),
|
|
425
944
|
relation: 'denied',
|
|
426
|
-
object: `deny_binding:${scopeKey(
|
|
945
|
+
object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
|
|
427
946
|
},
|
|
428
947
|
], { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
|
|
429
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* Holders con asignación vigente del rol en el scope exacto: `Read` por
|
|
951
|
+
* objeto exacto, paginado, con la caducidad filtrada en cliente. Antes era
|
|
952
|
+
* `ListUsers`, que trunca al tope del servidor sin señal (L0.7).
|
|
953
|
+
*/
|
|
430
954
|
async listSubjects(role, scope) {
|
|
431
|
-
|
|
432
|
-
|
|
955
|
+
assertIdentity({ role, scope });
|
|
956
|
+
// Un rol que el catálogo no declara para ese nivel (en ningún owner) no
|
|
957
|
+
// tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
|
|
958
|
+
// árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
|
|
959
|
+
// se lee bajo su identidad canónica, y el rol tiene que existir AHÍ (3B ·
|
|
960
|
+
// B2); el que existe se lee por su uuid (3A).
|
|
961
|
+
const catalog = await this.catalog.view();
|
|
962
|
+
const asked = normalizeRoleQuery(role);
|
|
963
|
+
if (asked.uuid !== undefined ? catalog.roleByUuid(asked.uuid) === null : catalog.rolesNamed(asked.slug, asked.scopeType ?? scope.type).length === 0) {
|
|
964
|
+
return [];
|
|
965
|
+
}
|
|
966
|
+
const chain = await this.chain(scope, 'listSubjects');
|
|
967
|
+
if (!chain)
|
|
968
|
+
return [];
|
|
969
|
+
const declared = visibleRoleFor(catalog, asked, scope, chainKeysFrom(chain)[0]);
|
|
970
|
+
if (!declared)
|
|
971
|
+
return [];
|
|
433
972
|
const fgaToMorph = Object.fromEntries(Object.entries(this.holderTypes).map(([morph, fga]) => [fga, morph]));
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
973
|
+
const tuples = await this.readAllTuples({
|
|
974
|
+
relation: 'assignee',
|
|
975
|
+
object: `role_binding:${scopeKey(chain[0])}|${declared.uuid}`,
|
|
976
|
+
});
|
|
977
|
+
const results = [];
|
|
978
|
+
for (const tuple of tuples) {
|
|
979
|
+
// `<tipoFga>:<uuid>`; un userset (`#`) o un tipo que no está en el mapa
|
|
980
|
+
// no es un holder que este driver haya escrito.
|
|
981
|
+
const separator = tuple.user.indexOf(':');
|
|
982
|
+
const fgaType = separator > 0 ? tuple.user.slice(0, separator) : '';
|
|
983
|
+
const uuid = separator > 0 ? tuple.user.slice(separator + 1) : '';
|
|
984
|
+
const morph = fgaToMorph[fgaType];
|
|
985
|
+
if (!morph || !uuid || uuid.includes('#')) {
|
|
986
|
+
this.diagnostics.unparseableBindings += 1;
|
|
987
|
+
this.warn(`authz(openfga): el user '${tuple.user}' de '${tuple.object}' no es un holder del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
988
|
+
continue;
|
|
445
989
|
}
|
|
990
|
+
results.push({ type: morph, uuid });
|
|
446
991
|
}
|
|
447
992
|
return results;
|
|
448
993
|
}
|
|
449
|
-
/**
|
|
450
|
-
|
|
451
|
-
|
|
994
|
+
/**
|
|
995
|
+
* Bindings del subject ya parseados (asignaciones directas vigentes). Los
|
|
996
|
+
* ids que no se entienden se registran y se cuentan, no se descartan.
|
|
997
|
+
*/
|
|
998
|
+
async listBindings(subject, at) {
|
|
999
|
+
const tuples = await this.readAllTuples({
|
|
452
1000
|
user: this.fgaSubject(subject),
|
|
453
1001
|
relation: 'assignee',
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
1002
|
+
object: 'role_binding:',
|
|
1003
|
+
}, { at });
|
|
1004
|
+
return this.parseBindings('role_binding', tuples.map((t) => t.object));
|
|
1005
|
+
}
|
|
1006
|
+
/** Scopes (por clave) donde el subject tiene un deny directo del permiso (por su uuid). */
|
|
1007
|
+
async deniedScopeKeys(subject, permissionUuid, at) {
|
|
1008
|
+
const tuples = await this.readAllTuples({
|
|
1009
|
+
user: this.fgaSubject(subject),
|
|
1010
|
+
relation: 'denied',
|
|
1011
|
+
object: 'deny_binding:',
|
|
1012
|
+
}, { at });
|
|
1013
|
+
return new Set(this.parseBindings('deny_binding', tuples.map((t) => t.object))
|
|
1014
|
+
.filter((p) => p.uuid === permissionUuid)
|
|
1015
|
+
.map((p) => scopeKey(p.scope)));
|
|
1016
|
+
}
|
|
1017
|
+
parseBindings(type, objects) {
|
|
1018
|
+
const parsed = [];
|
|
1019
|
+
for (const obj of objects) {
|
|
1020
|
+
const id = obj.replace(new RegExp(`^${type}:`), '');
|
|
1021
|
+
const binding = parseBindingId(id);
|
|
1022
|
+
if (binding) {
|
|
1023
|
+
parsed.push(binding);
|
|
1024
|
+
}
|
|
1025
|
+
else {
|
|
1026
|
+
this.diagnostics.unparseableBindings += 1;
|
|
1027
|
+
this.warn(`authz(openfga): binding '${type}:${id}' no tiene la forma del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return parsed;
|
|
1031
|
+
}
|
|
1032
|
+
warn(message) {
|
|
1033
|
+
this.logger.warn(message);
|
|
458
1034
|
}
|
|
459
1035
|
async listRoles(subject, scope) {
|
|
460
|
-
|
|
1036
|
+
assertIdentity({ subject, scope });
|
|
1037
|
+
// Un scope que el árbol no conoce no existe para el motor (D8): nada.
|
|
1038
|
+
const chain = await this.chain(scope, 'listRoles');
|
|
1039
|
+
if (!chain)
|
|
1040
|
+
return [];
|
|
1041
|
+
const prefix = scopeKey(chain[0]);
|
|
1042
|
+
const keys = chainKeysFrom(chain)[0];
|
|
1043
|
+
const catalog = await this.catalog.view();
|
|
461
1044
|
const roles = new Set();
|
|
462
|
-
for (const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
1045
|
+
for (const binding of await this.listBindings(subject)) {
|
|
1046
|
+
if (scopeKey(binding.scope) !== prefix)
|
|
1047
|
+
continue;
|
|
1048
|
+
const declared = this.declaredRole(catalog, binding, keys);
|
|
1049
|
+
if (declared)
|
|
1050
|
+
roles.add(declared.slug);
|
|
466
1051
|
}
|
|
467
1052
|
return [...roles];
|
|
468
1053
|
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Roles directos vigentes del holder en cada scope de la cadena (2D · G5):
|
|
1056
|
+
* UNA lectura (`Read` paginado de sus bindings) agrupada por scope, en vez
|
|
1057
|
+
* de un `listRoles` por nivel. Solo roles que el catálogo declara para ese
|
|
1058
|
+
* nivel (D5). La cadena viene ya resuelta por el manager.
|
|
1059
|
+
*/
|
|
1060
|
+
async rolesInChain(subject, chain) {
|
|
1061
|
+
assertIdentity({ subject });
|
|
1062
|
+
for (const scope of chain)
|
|
1063
|
+
assertScope(scope);
|
|
1064
|
+
if (chain.length === 0)
|
|
1065
|
+
return [];
|
|
1066
|
+
const catalog = await this.catalog.view();
|
|
1067
|
+
const keysFrom = chainKeysFrom(chain);
|
|
1068
|
+
const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
|
|
1069
|
+
const seen = new Set();
|
|
1070
|
+
const result = [];
|
|
1071
|
+
for (const binding of await this.listBindings(subject)) {
|
|
1072
|
+
const id = scopeKey(binding.scope);
|
|
1073
|
+
const level = wanted.get(id);
|
|
1074
|
+
if (!level)
|
|
1075
|
+
continue;
|
|
1076
|
+
const scope = level.scope;
|
|
1077
|
+
const declared = this.declaredRole(catalog, binding, level.keys);
|
|
1078
|
+
if (!declared)
|
|
1079
|
+
continue;
|
|
1080
|
+
// Dedupe por IDENTIDAD (3D · M1: el uuid, no el slug).
|
|
1081
|
+
const dedupe = `${id}\u001f${declared.uuid}`;
|
|
1082
|
+
if (seen.has(dedupe))
|
|
1083
|
+
continue;
|
|
1084
|
+
seen.add(dedupe);
|
|
1085
|
+
result.push({ scope, role: declared });
|
|
1086
|
+
}
|
|
1087
|
+
return result;
|
|
1088
|
+
}
|
|
469
1089
|
async listRoleScopes(subject, scopeType) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1090
|
+
assertIdentity({ subject, scopeType });
|
|
1091
|
+
const catalog = await this.catalog.view();
|
|
1092
|
+
const byScope = new Map();
|
|
1093
|
+
for (const binding of await this.listBindings(subject)) {
|
|
1094
|
+
if (binding.scope.type !== scopeType)
|
|
1095
|
+
continue;
|
|
1096
|
+
const k = scopeKey(binding.scope);
|
|
1097
|
+
if (!byScope.has(k))
|
|
1098
|
+
byScope.set(k, { scope: binding.scope, bindings: [] });
|
|
1099
|
+
byScope.get(k).bindings.push(binding);
|
|
1100
|
+
}
|
|
1101
|
+
// Los scopes que el árbol ya no conoce no se listan (D8): una consulta
|
|
1102
|
+
// al resolutor por scope, el mismo coste que `listScopes`. Y con la
|
|
1103
|
+
// cadena se decide si alguno de sus roles existe ahí (D5, 3B · B2).
|
|
1104
|
+
const known = [];
|
|
1105
|
+
for (const { scope, bindings } of byScope.values()) {
|
|
1106
|
+
const chain = await this.chain(scope, 'listRoleScopes');
|
|
1107
|
+
if (!chain)
|
|
1108
|
+
continue;
|
|
1109
|
+
const keys = chainKeysFrom(chain)[0];
|
|
1110
|
+
if (bindings.some((b) => this.declaredRole(catalog, b, keys)))
|
|
1111
|
+
known.push(scope);
|
|
476
1112
|
}
|
|
477
|
-
return
|
|
1113
|
+
return known;
|
|
478
1114
|
}
|
|
479
1115
|
async listScopes(subject, permission) {
|
|
480
|
-
|
|
1116
|
+
assertIdentity({ subject, permission });
|
|
1117
|
+
const catalog = await this.catalog.view();
|
|
1118
|
+
const perm = catalog.permission(permission);
|
|
481
1119
|
if (!perm)
|
|
482
1120
|
return [];
|
|
483
|
-
const granting =
|
|
484
|
-
// Denies del subject para este permiso
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const deniedKeys = new Set((denyResponse.objects ?? [])
|
|
491
|
-
.map((obj) => obj.replace(/^deny_binding:/, ''))
|
|
492
|
-
.map((id) => parseBindingId(id))
|
|
493
|
-
.filter((p) => Boolean(p && p.slug === permission))
|
|
494
|
-
.map((p) => scopeKey(p.scope)));
|
|
1121
|
+
const granting = catalog.rolesGranting(perm.uuid);
|
|
1122
|
+
// Denies directos del subject para este permiso: TODOS, paginando. Con
|
|
1123
|
+
// `ListObjects` el tope del servidor se consumía con los denies de
|
|
1124
|
+
// cualquier permiso y el relevante podía quedar fuera: fail-open (L0.7).
|
|
1125
|
+
// Las dos lecturas filtran la caducidad con el MISMO instante (K9).
|
|
1126
|
+
const at = this.now();
|
|
1127
|
+
const deniedKeys = await this.deniedScopeKeys(subject, perm.uuid, at);
|
|
495
1128
|
const result = new Map();
|
|
496
|
-
for (const
|
|
497
|
-
const
|
|
498
|
-
if (!
|
|
1129
|
+
for (const binding of await this.listBindings(subject, at)) {
|
|
1130
|
+
const role = (granting.get(binding.scope.type) ?? []).find((r) => r.uuid === binding.uuid);
|
|
1131
|
+
if (!role)
|
|
1132
|
+
continue;
|
|
1133
|
+
// Un scope que el árbol ya no conoce no concede: no se lista. Y el rol
|
|
1134
|
+
// tiene que existir ahí (3B · B2: global u owner en la cadena).
|
|
1135
|
+
const chain = await this.chain(binding.scope, 'listScopes');
|
|
1136
|
+
if (!chain)
|
|
499
1137
|
continue;
|
|
500
|
-
if (!(
|
|
1138
|
+
if (!isRoleVisibleWith(role, chainKeysFrom(chain)[0]))
|
|
501
1139
|
continue;
|
|
502
|
-
const chain = await this.chain(parsed.scope);
|
|
503
1140
|
const blocked = chain.some((s) => deniedKeys.has(scopeKey(s)));
|
|
504
1141
|
if (!blocked)
|
|
505
|
-
result.set(scopeKey(
|
|
1142
|
+
result.set(scopeKey(binding.scope), binding.scope);
|
|
506
1143
|
}
|
|
507
1144
|
return [...result.values()];
|
|
508
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Denies directos del holder (2.1, B5): `Read` paginado de sus
|
|
1148
|
+
* `deny_binding` (nunca ListObjects, L0.7), filtrados por el catálogo (un
|
|
1149
|
+
* permiso retirado no es un deny, D5), por scope exacto si se pide, y por
|
|
1150
|
+
* scopes que el árbol conoce (D8).
|
|
1151
|
+
*/
|
|
1152
|
+
async listDenies(subject, scope) {
|
|
1153
|
+
assertIdentity(scope ? { subject, scope } : { subject });
|
|
1154
|
+
const chain = scope ? await this.chain(scope, 'listDenies') : null;
|
|
1155
|
+
if (scope && !chain)
|
|
1156
|
+
return [];
|
|
1157
|
+
const wanted = chain ? scopeKey(chain[0]) : null;
|
|
1158
|
+
const view = await this.catalog.view();
|
|
1159
|
+
const tuples = await this.readAllTuples({
|
|
1160
|
+
user: this.fgaSubject(subject),
|
|
1161
|
+
relation: 'denied',
|
|
1162
|
+
object: 'deny_binding:',
|
|
1163
|
+
});
|
|
1164
|
+
const result = [];
|
|
1165
|
+
for (const binding of this.parseBindings('deny_binding', tuples.map((t) => t.object))) {
|
|
1166
|
+
const permission = view.permissionSlug(binding.uuid);
|
|
1167
|
+
if (!permission)
|
|
1168
|
+
continue;
|
|
1169
|
+
if (wanted !== null) {
|
|
1170
|
+
if (scopeKey(binding.scope) !== wanted)
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
else if (!(await this.chain(binding.scope, 'listDenies'))) {
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
result.push({ permission, scope: binding.scope });
|
|
1177
|
+
}
|
|
1178
|
+
return result;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Purga del scope exacto en FGA (N7, S6, B2). No hay "borrar todo lo de
|
|
1182
|
+
* este objeto": se leen por objeto EXACTO los bindings posibles — un
|
|
1183
|
+
* `role_binding` por cada rol del catálogo de ese `scope_type` y un
|
|
1184
|
+
* `deny_binding` por cada permiso — paginando `Read` (nunca ListObjects:
|
|
1185
|
+
* trunca sin avisar, L0.7), se borra en lotes ≤ 100 (límite del Write) y
|
|
1186
|
+
* se vuelve a leer cada objeto: si queda algo, se lanza. Un rol retirado
|
|
1187
|
+
* del catálogo deja bindings inalcanzables por esta vía; es el precio de no
|
|
1188
|
+
* tener un índice por objeto, y lo vigilará `authz:reconcile` (3b).
|
|
1189
|
+
*/
|
|
1190
|
+
async purgeScope(purged) {
|
|
1191
|
+
assertScope(purged);
|
|
1192
|
+
if (purged.type === APP_SCOPE_TYPE) {
|
|
1193
|
+
throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
|
|
1194
|
+
}
|
|
1195
|
+
// La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
|
|
1196
|
+
const scope = await this.canonicalOrSelf(purged, 'purgeScope');
|
|
1197
|
+
const key = scopeKey(scope);
|
|
1198
|
+
const roles = await this.sql('purgeScope.roles', () => db.from('authz_roles').where('scope_type', scope.type).select('uuid'));
|
|
1199
|
+
const permissions = await this.sql('purgeScope.permissions', () => db.from('authz_permissions').select('uuid'));
|
|
1200
|
+
const objects = [
|
|
1201
|
+
...roles.map((r) => `role_binding:${key}|${r.uuid}`),
|
|
1202
|
+
...permissions.map((p) => `deny_binding:${key}|${p.uuid}`),
|
|
1203
|
+
];
|
|
1204
|
+
for (const object of objects) {
|
|
1205
|
+
const keys = await this.readAllTuples({ object }, { includeExpired: true });
|
|
1206
|
+
for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
|
|
1207
|
+
await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
|
|
1208
|
+
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Demostrar cero: lo que no se puede demostrar, se reporta.
|
|
1213
|
+
const residue = [];
|
|
1214
|
+
for (const object of objects) {
|
|
1215
|
+
const left = await this.readAllTuples({ object }, { includeExpired: true });
|
|
1216
|
+
if (left.length)
|
|
1217
|
+
residue.push(`${object} (${left.length})`);
|
|
1218
|
+
}
|
|
1219
|
+
if (residue.length) {
|
|
1220
|
+
throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
|
|
1221
|
+
`No confirmes el borrado del scope; reintenta la purga.`);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// `purgeRole` NO existe en este driver (3B · B4, capacidad `purgeRole:
|
|
1225
|
+
// false`): los bindings de un rol viven en objetos
|
|
1226
|
+
// `role_binding:<scopeKey>|<roleUuid>` de scopes que el driver no puede
|
|
1227
|
+
// enumerar por rol sin leer el store entero (`Read` filtra por prefijo de
|
|
1228
|
+
// objeto, no por sufijo). Borrar la fila del catálogo sin sus tuplas
|
|
1229
|
+
// dejaría hechos huérfanos que resucitarían al recrear el slug.
|
|
1230
|
+
//
|
|
1231
|
+
// Hasta 3E el método existía y lanzaba 500 al LLAMARLO, y eso era el
|
|
1232
|
+
// callejón que encontró el code-review (3E · P4): `defineScopedRole`
|
|
1233
|
+
// escribía el rol tan tranquilo y después nada podía borrarlo — ni
|
|
1234
|
+
// `deleteScopedRole` ni `scopes.detached` de ese scope, para siempre.
|
|
1235
|
+
// Desde 3E el método es OPCIONAL en el puerto (Q4) y NO declararlo es la
|
|
1236
|
+
// forma de decir «no sé purgar»: el manager lo comprueba ANTES de escribir
|
|
1237
|
+
// (500 `E_AUTHZ_UNSUPPORTED` nombrándolo). Llega con 3b (`facts` +
|
|
1238
|
+
// `authz:reconcile`).
|
|
1239
|
+
/**
|
|
1240
|
+
* TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar
|
|
1241
|
+
* el `continuation_token`, sin las caducadas. Es la única primitiva de
|
|
1242
|
+
* enumeración del driver (L0.7): `Read` no tiene tope de resultados —a
|
|
1243
|
+
* diferencia de `ListObjects`/`ListUsers`, que cortan al máximo del
|
|
1244
|
+
* servidor sin ninguna señal— y devuelve la condición de cada tupla, así
|
|
1245
|
+
* que la caducidad se filtra aquí con el mismo reloj que `checkContext`.
|
|
1246
|
+
*
|
|
1247
|
+
* Contrapartida, documentada en el README: `Read` devuelve tuplas
|
|
1248
|
+
* ESCRITAS, no relaciones computadas. Con el modelo que genera este paquete
|
|
1249
|
+
* (`assignee`/`denied` directas) es exactamente lo mismo; un modelo
|
|
1250
|
+
* extendido con relaciones derivadas sobre `role_binding` no se enumeraría
|
|
1251
|
+
* por aquí.
|
|
1252
|
+
*/
|
|
1253
|
+
async readAllTuples(filter, options = {}) {
|
|
1254
|
+
// El instante con el que se filtra: el de la operación si lo trae (K9), o
|
|
1255
|
+
// el de esta lectura.
|
|
1256
|
+
const now = options.at ?? this.now();
|
|
1257
|
+
const keys = [];
|
|
1258
|
+
let continuationToken;
|
|
1259
|
+
const seenTokens = new Set();
|
|
1260
|
+
let pages = 0;
|
|
1261
|
+
do {
|
|
1262
|
+
const response = await this.client.read(filter, {
|
|
1263
|
+
pageSize: READ_PAGE_SIZE,
|
|
1264
|
+
continuationToken,
|
|
1265
|
+
consistency: this.consistency,
|
|
1266
|
+
});
|
|
1267
|
+
pages += 1;
|
|
1268
|
+
for (const tuple of response.tuples ?? []) {
|
|
1269
|
+
const k = tuple?.key;
|
|
1270
|
+
if (!k?.user || !k?.relation || !k?.object) {
|
|
1271
|
+
// Una tupla que el motor no puede leer es un hecho que las
|
|
1272
|
+
// enumeraciones NO muestran: se cuenta y se registra (L0.16, H16).
|
|
1273
|
+
this.diagnostics.unparseableBindings += 1;
|
|
1274
|
+
this.warn(`authz(openfga): tupla malformada en Read ${JSON.stringify(filter)} (${JSON.stringify(k ?? null)}); se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (!options.includeExpired) {
|
|
1278
|
+
const validUntil = toExpiryDate(k.condition?.context?.valid_until);
|
|
1279
|
+
if (validUntil && validUntil <= now)
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
keys.push({ user: k.user, relation: k.relation, object: k.object });
|
|
1283
|
+
}
|
|
1284
|
+
continuationToken = response.continuation_token || undefined;
|
|
1285
|
+
if (continuationToken) {
|
|
1286
|
+
if (seenTokens.has(continuationToken)) {
|
|
1287
|
+
throw new AuthorizationInternalError(`Read ${JSON.stringify(filter)}: el continuation_token se repite (página ${pages}); el servidor no avanza`);
|
|
1288
|
+
}
|
|
1289
|
+
if (pages >= MAX_READ_PAGES) {
|
|
1290
|
+
throw new AuthorizationInternalError(`Read ${JSON.stringify(filter)}: más de ${MAX_READ_PAGES} páginas sin agotar el continuation_token`);
|
|
1291
|
+
}
|
|
1292
|
+
seenTokens.add(continuationToken);
|
|
1293
|
+
}
|
|
1294
|
+
} while (continuationToken);
|
|
1295
|
+
return keys;
|
|
1296
|
+
}
|
|
509
1297
|
}
|
|
510
1298
|
//# sourceMappingURL=openfga_driver.js.map
|