@jantstack/adonis-authz 2.5.0-alpha.4 → 2.5.0-rc.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.
@@ -85,7 +85,9 @@ const MYSQL_LOCK_NAME_MAX = 64;
85
85
  * coincidieran en los primeros 64 COMPARTÍAN lease y se excluían para siempre
86
86
  * — y el modo de fallo era `busy: true`, que no lanza: el relay de uno de los
87
87
  * dos no corría nunca, en silencio. Desde alpha.5 el nombre es un hash
88
- * ESTABLE del nombre completo (`current`, 59 caracteres), que no colisiona.
88
+ * ESTABLE del nombre completo (`current`, 59 caracteres), que no colisiona entre
89
+ * TABLAS — entre BASES del mismo servidor sí: el «nombre completo» no llevaba la
90
+ * base (rotación 2 · R2-01, abajo).
89
91
  *
90
92
  * **Transición**: `sqlScopeOutbox.acquire()` toma el lock viejo (`legacy`, el
91
93
  * recortado) Y el nuevo durante alpha.5, porque en un rolling deploy un
@@ -114,13 +116,47 @@ export const SCOPE_OUTBOX_LEGACY_LOCK_UNTIL = '2.4.0-alpha.6';
114
116
  * desde ≤ alpha.4 en MySQL exige parada completa del relay, no despliegue rodante.
115
117
  */
116
118
  export const SCOPE_OUTBOX_TAKES_LEGACY_LOCK = false;
117
- export function scopeOutboxLockNames(connection, table) {
119
+ /**
120
+ * **La identidad de la BASE en el nombre del lease** (rotación 2 de M4 · R2-01, decisión del dueño 2026-10-22 (1)).
121
+ * `GET_LOCK` es un cerrojo de SERVIDOR, y hasta 2.5.0-alpha.4 el nombre era `<conexión>:<tabla>`: dos despliegues en
122
+ * BASES distintas del mismo MySQL con la conexión por defecto de Adonis y la tabla del stub calculaban el MISMO nombre y
123
+ * se excluían (`busy`, exit 0) — el síntoma que R6 de alpha.5 daba por cerrado nunca lo estuvo, porque el «nombre
124
+ * completo» no llevaba la base. Desde este cierre `current` lleva la base que la PROPIA conexión que sostiene el lease
125
+ * lee con `DATABASE()` (no la del config: es la base contra la que esa sesión resuelve la tabla sin cualificar, o sea la
126
+ * identidad real de la cola; un alias de host o una URL distinta que apunte a la misma base dan el mismo nombre).
127
+ *
128
+ * En MINÚSCULAS a propósito: los dos errores posibles no cuestan lo mismo. Fundir `App` y `app` (dos bases distintas en
129
+ * un servidor sensible a mayúsculas) solo hace que esos dos despliegues compartan lease —vivacidad, `busy`—; separar la
130
+ * misma base escrita de dos formas por dos procesos del MISMO despliegue serían dos escritores —seguridad—. Se elige el
131
+ * error barato. `DATABASE()` `NULL` (sesión sin base por defecto: la tabla tiene que ir cualificada) entra como cadena
132
+ * vacía: los procesos de ese despliegue siguen calculando el mismo nombre.
133
+ *
134
+ * Los tres nombres: `legacy` (el recortado a 64 de ≤ alpha.4, retirado en alpha.7, se conserva para el caso que lo
135
+ * fija), `unscoped` (el sha1 sin base de 2.4.0-alpha.5..2.5.0-alpha.4) y `current` (el sha1 con base). Sin `database`
136
+ * —quien no ha abierto conexión: los casos de identidad pura— `current` es `unscoped`.
137
+ */
138
+ export function scopeOutboxLockNames(connection, table, database) {
118
139
  const full = `authz_scope_outbox:${connection ?? 'default'}:${table}`;
140
+ const sha = (value) => `authz_scope_outbox:${createHash('sha1').update(value).digest('hex')}`;
141
+ const unscoped = sha(full);
119
142
  return {
120
143
  legacy: full.slice(0, MYSQL_LOCK_NAME_MAX),
121
- current: `authz_scope_outbox:${createHash('sha1').update(full).digest('hex')}`,
144
+ unscoped,
145
+ current: database === undefined ? unscoped : sha(`${full}@db:${String(database ?? '').toLowerCase()}`),
122
146
  };
123
147
  }
148
+ /**
149
+ * **La transición del nombre sin base** (rotación 2 · R2-01, firmada: «como alpha.5»). En 2.5.x `acquire()` toma el
150
+ * nombre VIEJO (`unscoped`, el de 2.4.0-alpha.5..2.5.0-alpha.4) y DESPUÉS el nuevo (`current`, con la base), y suelta
151
+ * los dos: en un despliegue rodante un proceso de alpha.4 solo conoce el viejo, y los dos tienen que seguir
152
+ * excluyéndose. **El precio, declarado**: mientras se tome el viejo, dos despliegues en bases distintas del mismo
153
+ * servidor SIGUEN compartiéndolo — R2-01 queda abierto en 2.5.x y se cierra al retirarlo en 2.6 (README «Limits»,
154
+ * `relay.mysql-unscoped-lease`; backlog 2.6). Atado a la versión como en alpha.6: `tests/rotacion2_cierre.spec.ts` exige
155
+ * que valga exactamente «`package.json` < `SCOPE_OUTBOX_UNSCOPED_LOCK_RETIRED_IN`» (`2.6.0-alpha.0`: toda 2.6, sus
156
+ * prereleases incluidas), así que el bump a 2.6 pone la suite en rojo hasta retirarlo.
157
+ */
158
+ export const SCOPE_OUTBOX_UNSCOPED_LOCK_RETIRED_IN = '2.6.0-alpha.0';
159
+ export const SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK = true;
124
160
  /** Hash de 32 bits con signo (lo que acepta `pg_try_advisory_xact_lock`). */
125
161
  function hash32(value) {
126
162
  let hash = 0;
@@ -374,7 +410,6 @@ export function sqlScopeOutbox(options = {}, database = db) {
374
410
  * pasada, que es el precio de que dos pasadas no se pisen.
375
411
  */
376
412
  async acquire() {
377
- const names = scopeOutboxLockNames(connection, table);
378
413
  /** El nombre del cerrojo de PROCESO y del advisory de PG: el completo (allí no hay recorte). */
379
414
  const name = `authz_scope_outbox:${connection ?? 'default'}:${table}`;
380
415
  const client = lockClientOf(database, connection);
@@ -397,11 +432,17 @@ export function sqlScopeOutbox(options = {}, database = db) {
397
432
  return { release: async () => void (await trx.rollback()) };
398
433
  }
399
434
  if (dialect.startsWith('mysql')) {
400
- // **Los DOS locks** (alpha.5 · R6, transición): el nuevo —hash
401
- // estable, sin colisión— y el viejo —el recortado a 64 de alpha.4—,
402
- // para que un rolling deploy con las dos versiones siga siendo
403
- // escritor único. Si cualquiera de los dos falla, se sueltan los que
404
- // se tomaron y es `busy`. El viejo se retira en 2.5.
435
+ // **La base en el nombre** (rotación 2 · R2-01): la lee la MISMA sesión que va a sostener el cerrojo.
436
+ const database = firstValue(await query('acquire', () => trx.rawQuery('select database() as db')), 'db');
437
+ const names = scopeOutboxLockNames(connection, table, database === null || database === undefined ? null : String(database));
438
+ // **Transición** (2.5.x): el VIEJO (sin base) primero y el NUEVO después; si cualquiera falla se sueltan los
439
+ // ya tomados y es `busy`. Un proceso de alpha.4 solo toma el viejo: los dos siguen excluyéndose.
440
+ // (El recortado de ≤ alpha.4 sigue retirado: `SCOPE_OUTBOX_TAKES_LEGACY_LOCK` es `false` desde alpha.7.)
441
+ const order = [
442
+ ...(SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK ? [names.unscoped] : []),
443
+ names.current,
444
+ ...(SCOPE_OUTBOX_TAKES_LEGACY_LOCK ? [names.legacy] : []),
445
+ ];
405
446
  const taken = [];
406
447
  const take = async (lockName) => {
407
448
  const result = await query('acquire', () => trx.rawQuery('select get_lock(?, 0) as taken', [lockName]));
@@ -410,18 +451,38 @@ export function sqlScopeOutbox(options = {}, database = db) {
410
451
  taken.push(lockName);
411
452
  return ok;
412
453
  };
454
+ // `GET_LOCK` es de SESIÓN, no de la transacción: revertir no lo suelta, y una conexión que vuelve al pool
455
+ // con el cerrojo puesto deja la cola `busy` para siempre. Se sueltan TODOS los tomados (en orden inverso)
456
+ // aunque uno falle, y la transacción se revierte al final pase lo que pase.
457
+ let released = false;
413
458
  const releaseAll = async () => {
414
- try {
415
- for (const lockName of taken.reverse())
459
+ if (released)
460
+ return;
461
+ released = true;
462
+ let failure;
463
+ for (const lockName of [...taken].reverse()) {
464
+ try {
416
465
  await trx.rawQuery('select release_lock(?)', [lockName]);
466
+ }
467
+ catch (error) {
468
+ failure ??= error;
469
+ }
417
470
  }
418
- finally {
419
- await trx.rollback();
420
- }
471
+ await trx.rollback();
472
+ if (failure !== undefined)
473
+ throw failure;
421
474
  };
422
- if (!(await take(names.current)) || (SCOPE_OUTBOX_TAKES_LEGACY_LOCK && !(await take(names.legacy)))) {
423
- await releaseAll();
424
- return null;
475
+ try {
476
+ for (const lockName of order) {
477
+ if (!(await take(lockName))) {
478
+ await releaseAll();
479
+ return null;
480
+ }
481
+ }
482
+ }
483
+ catch (error) {
484
+ await releaseAll().catch(() => { });
485
+ throw error;
425
486
  }
426
487
  return { release: releaseAll };
427
488
  }
@@ -132,8 +132,10 @@ export interface DriverCapabilities {
132
132
  * y MySQL, y es la promesa que el README hace.
133
133
  *
134
134
  * Con `false` (SQLite, que serializa bloqueando la BASE ENTERA) el juez
135
- * solo puede exigir lo innegociable: nunca dos ganadores y el perdedor no
136
- * escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
135
+ * solo puede exigir lo innegociable: nunca dos ganadores con el MISMO owner
136
+ * (el del ancestro puede ensombrecer al del descendiente: los dos finales
137
+ * legales de 3G · Y1, también en esta cara desde la rotación 2) y el
138
+ * perdedor no escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
137
139
  * legítimo. Declararlo `true` en un motor así convierte un flake en un
138
140
  * fallo; declararlo `false` en PG/MySQL deja pasar un mutante que
139
141
  * convierte la colisión en 503. Solo tiene casos en `level: '2.2'`.
@@ -207,6 +209,21 @@ export interface DriverCapabilities {
207
209
  * declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
208
210
  */
209
211
  experimental?: boolean;
212
+ /**
213
+ * **Toda decisión que combina concesión y deny se lee en UNA foto** (rotación 2 · cierre 3, L12;
214
+ * `AuthorizationDriverCapabilities.snapshotDecisions`). Opcional (regla D15: un harness publicado sin la clave sigue
215
+ * valiendo y cuenta como `false`). Solo tiene casos en `level: '2.1'` o superior (necesita `listDenies`). Con `true`
216
+ * el juez fija que el driver trae `readSnapshot`, que cada lectura pedida es EXACTAMENTE la del método homónimo (y lo
217
+ * no pedido no viene), que una pregunta mal formada es 422, y que el manager compone `effectivePermissions`,
218
+ * `authorizedScopes` y la policy de `defineScopedRole` con UNA llamada a `readSnapshot` y ninguna a
219
+ * `rolesInChain`/`listScopes`/`listDenies` (el espía cuenta solo las llamadas del MANAGER: lo que `readSnapshot` llame
220
+ * por dentro no cuenta). Con `false`, que el manager NO llama a `readSnapshot` —aunque el driver traiga un método con
221
+ * ese nombre: decide la capacidad— y compone como antes restando los denies (el hueco declarado de un driver sin la
222
+ * capacidad); sin `listDenies` la cara se queda en la declaración y su título lo dice. Que la foto sea de verdad UNA no lo puede
223
+ * observar un juez genérico sin meterse en el backend: en `database` lo fijan los casos de entrelazado forzado y la
224
+ * guardia `database.decision.snapshot` del paquete.
225
+ */
226
+ snapshotDecisions?: boolean;
210
227
  }
211
228
  export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
212
229
  export interface DriverContractHarness {
@@ -3017,7 +3017,7 @@ export function registerAuthorizationDriverContract(harness, api) {
3017
3017
  // dejaba vivo un mutante que convertía la colisión en un 503.
3018
3018
  const raceOfDefines = (serialized) => since('2.2', serialized
3019
3019
  ? 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: EXACTAMENTE uno gana y el perdedor es 422 E_AUTHZ_CATALOG_CONFLICT — la unicidad no es una carrera'
3020
- : 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
3020
+ : 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores con el MISMO owner (el del ancestro puede ensombrecer al del descendiente) y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
3021
3021
  // 3D · M2 (auditor V2 🔴 A, reproducido en PG: los dos se insertaban
3022
3022
  // porque el unique de la base es (slug, scope_type, owner_scope_key)
3023
3023
  // y cada uno comprobaba la colisión contra SU foto del memo). Ahora
@@ -3060,11 +3060,21 @@ export function registerAuthorizationDriverContract(harness, api) {
3060
3060
  assert.oneOf(ok.length, [1, 2], `un ganador, o el del ancestro ensombreciendo al del descendiente (${estado})`);
3061
3061
  }
3062
3062
  else {
3063
- // Sin serialización garantizada: nunca dos. Cuántos confirman
3064
- // depende del motor — SQLite bloquea la base entera y el segundo
3065
- // escritor puede recibir `SQLITE_BUSY`, un fallo de backend
3066
- // legítimo (503) porque su transacción NO se aplicó.
3067
- assert.isAtMost(ok.length, 1, `nunca dos ganadores (${estado})`);
3063
+ // Sin serialización garantizada cuántos confirman depende del
3064
+ // motor — SQLite bloquea la base entera y el segundo escritor
3065
+ // puede recibir `SQLITE_BUSY`, un fallo de backend legítimo (503)
3066
+ // porque su transacción NO se aplicó—, así que cero también vale.
3067
+ // Y los mismos DOS finales legales que la cara `true` (rotación 2 ·
3068
+ // cierre 2 · T-07): desde R2-05 el re-chequeo en la transacción
3069
+ // decide por AUTORIDAD, así que si el del descendiente confirma
3070
+ // mientras el del ancestro valida, el del ancestro ENTRA y lo
3071
+ // ensombrece (final 2 de 3G · Y1). Hasta alpha.4 aquí se afirmaba
3072
+ // «como mucho uno», que un backend sin el bloqueo de SQLite podía
3073
+ // poner rojo sin defecto (aserción flaky en el artefacto
3074
+ // publicado). Lo innegociable se afirma abajo igual que en la
3075
+ // cara `true`: nunca dos con el MISMO owner y, si son dos, el
3076
+ // extra es el del ANCESTRO.
3077
+ assert.isAtMost(ok.length, 2, `nunca dos ganadores con el MISMO owner (${estado})`);
3068
3078
  }
3069
3079
  for (const rejected of results.filter((r) => r.status === 'rejected')) {
3070
3080
  const error = rejected.reason;
@@ -3161,22 +3171,18 @@ export function registerAuthorizationDriverContract(harness, api) {
3161
3171
  await insertRoleUnseen({ slug: 'sigiloso', scopeType: 'unit', owner: scopeKey(orgA) });
3162
3172
  await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'sigiloso' }), conflicto);
3163
3173
  assert.lengthOf(await rowsNamed('sigiloso', 'unit'), 1, 'no se escribe a ciegas sobre lo que el memo no vio');
3164
- // Y la rama del re-chequeo que NO se puede juzgar ahí dentro (3F ·
3165
- // U3, tester 3E · §4.2): un homónimo cuyo owner no es global ni
3166
- // ancestro-o-igual del owner pedido —una unit HERMANA— no se
3167
- // clasifica sin resolver su cadena, y resolverla con el cerrojo del
3168
- // catálogo sostenido sería un abrazo mortal con un pool de 1. Se
3169
- // rechaza igual, con el 422 que pide REINTENTAR, y no escribe.
3170
- // Hasta 3F esa rama solo la observaba la carrera de arriba, y solo
3171
- // en el harness cuyo árbol SQL ensancha la ventana: aquí va EN
3172
- // SERIE (el memo se recalienta primero, así que la fila nueva le es
3173
- // invisible y solo la relectura de la BASE la ve).
3174
+ // Y la rama del re-chequeo de un owner que NO es global ni ancestro-o-igual del owner pedido —una unit
3175
+ // HERMANA— (3F · U3, tester 3E · §4.2; volteada en la rotación 2 de M4 · R2-05). Hasta 2.5.0-alpha.4 se
3176
+ // rechazaba con un 422 «reintenta» que NOMBRABA el owner del hermano (otro tenant) y la colisión era falsa:
3177
+ // dos hermanos pueden compartir slug. Ahora se juzga con la MISMA regla de autoridad que la comprobación
3178
+ // previa y entra. EN SERIE: el memo se recalienta primero, así que la fila nueva le es invisible y solo la
3179
+ // relectura de la BASE la ve.
3174
3180
  const unitA2 = await unitUnder(tree, orgA);
3175
3181
  assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin'], 'memo recalentado tras el fallo anterior');
3176
3182
  await insertRoleUnseen({ slug: 'lateral', scopeType: 'unit', owner: scopeKey(unitA2) });
3177
- const cambio = await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' }), conflicto);
3178
- assert.include(String(cambio?.message), 'El catálogo cambió mientras se validaba');
3179
- assert.lengthOf(await rowsNamed('lateral', 'unit'), 1, 'el 422 genérico tampoco escribe a ciegas');
3183
+ const lateral = await here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' });
3184
+ assert.equal(lateral.owner, scopeKey(unitA1), 'el homónimo de un HERMANO no es colisión: entra');
3185
+ assert.lengthOf(await rowsNamed('lateral', 'unit'), 2, 'los dos hermanos tienen su lateral@unit');
3180
3186
  });
3181
3187
  caseFor('serializedCatalogWrites', {
3182
3188
  whenTrue: () => raceOfDefines(true),
@@ -3815,6 +3821,162 @@ export function registerAuthorizationDriverContract(harness, api) {
3815
3821
  });
3816
3822
  },
3817
3823
  });
3824
+ // ── Rotación 2 · cierre 3 · `snapshotDecisions` (regla D15, linaje L12) ─────────
3825
+ // Una decisión que combina lo que el holder TIENE con lo que tiene DENEGADO, leída en dos métodos del puerto sin
3826
+ // foto común, puede responder lo que ningún estado real responde (un offboarding entre las dos lecturas). La
3827
+ // capacidad dice que el driver lee las dos mitades de una vez; lo que el juez observa es el MÉTODO (`readSnapshot`):
3828
+ // su respuesta es la de los métodos homónimos, y el manager lo usa en vez de componer.
3829
+ const snapshotSeed = async () => {
3830
+ const alice = subject();
3831
+ const orgA = await orgUnder(tree, APP_SCOPE);
3832
+ const unitA1 = await unitUnder(tree, orgA);
3833
+ await driver.grant(alice, 'org-editor', orgA);
3834
+ await driver.grant(alice, 'unit-editor', unitA1);
3835
+ await driver.grant(alice, 'viewer', APP_SCOPE);
3836
+ await driver.deny(alice, 'docs:read', unitA1);
3837
+ await driver.deny(alice, 'billing:read', orgA);
3838
+ return { alice, orgA, unitA1 };
3839
+ };
3840
+ const canonical = (items) => items.map((item) => JSON.stringify(item)).sort();
3841
+ /**
3842
+ * El espía del par (rotación 2 · cierre 3b, tester 🟡 6): cuenta SOLO las llamadas que hace el MANAGER al driver. Cada
3843
+ * método se reenvía con `this` = el driver de verdad (no el proxy), así que lo que un `readSnapshot` llame por dentro
3844
+ * (`this.rolesInChain()` dentro de una transacción REPEATABLE READ, p. ej.) no se cuenta: el juez fija el contrato
3845
+ * (UNA llamada del manager), no la implementación.
3846
+ */
3847
+ const watchManagerCalls = (target) => {
3848
+ const touched = [];
3849
+ // El proxy va sobre un objeto VACÍO (no sobre el driver): un driver congelado o con métodos no configurables no
3850
+ // choca con las invariantes de `Proxy`, y todo se lee del driver de verdad. Una VISTA que devuelve el driver
3851
+ // (`withChainResolver`, `withClock`: el manager las pide por request) se vigila igual, con la misma cuenta.
3852
+ const watch = (real) => new Proxy({}, {
3853
+ get: (_shell, prop) => {
3854
+ const value = Reflect.get(real, prop, real);
3855
+ if (typeof value === 'function' && typeof prop === 'string') {
3856
+ return (...args) => {
3857
+ touched.push(prop);
3858
+ const out = value.apply(real, args);
3859
+ const isView = out !== null && typeof out === 'object' && typeof out.then !== 'function' && typeof out.authorize === 'function';
3860
+ return isView ? watch(out) : out;
3861
+ };
3862
+ }
3863
+ return value;
3864
+ },
3865
+ has: (_shell, prop) => prop in real,
3866
+ });
3867
+ const watched = watch(target);
3868
+ const reads = () => touched.filter((name) => ['readSnapshot', 'rolesInChain', 'listScopes', 'listDenies', 'listRoles'].includes(name));
3869
+ return { watched, touched, reads };
3870
+ };
3871
+ /**
3872
+ * La parte de DELEGACIÓN de las dos caras (rotación 2 · cierre 3c, A/J1 del auditor y del tester): la policy de
3873
+ * `defineScopedRole` solo se juzga si el harness declara `purgeRole: true`. Sin `purgeRole` el manager no delega
3874
+ * (`defineScopedRole` es 500 `E_AUTHZ_UNSUPPORTED` ANTES de la policy, 3E · P4), y `purgeRole` es un par de `'2.2'`:
3875
+ * un tercero `'2.1'` con `listDenies` y sin `purgeRole` —legítimo, D15— salía ROJO con un caso nuevo. Decide la
3876
+ * DECLARACIÓN del harness, no la presencia del método, por el patrón del juez (como `listDenies` aquí mismo): una
3877
+ * declaración `true` solo es posible en `'2.2'` (en `'2.1'` el juez lanza al registrar un `true` sin caso) y ahí la
3878
+ * juzga su propio par —método presente y purga total, o ausente y 500—, mientras que un método presente en un
3879
+ * harness `'2.1'` no lo juzga nadie. El título de cada cara dice cuál de las dos se registró.
3880
+ */
3881
+ const snapshotDelegation = harness.capabilities.purgeRole === true;
3882
+ caseFor('snapshotDecisions', {
3883
+ whenTrue: () => {
3884
+ since('2.1', 'snapshotDecisions:true · readSnapshot responde cada lectura pedida EXACTAMENTE como el método homónimo (rolesInChain, listScopes, listDenies), lo no pedido no viene, un permiso fuera del catálogo da listScopes [] y una pregunta mal formada es 422', async ({ assert, }) => {
3885
+ assert.typeOf(driver.readSnapshot, 'function', 'snapshotDecisions: true exige readSnapshot en el puerto');
3886
+ assert.strictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: true y el driver no');
3887
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, true);
3888
+ const { alice, unitA1 } = await snapshotSeed();
3889
+ const chain = (await tree.chainOf(unitA1));
3890
+ const all = await driver.readSnapshot(alice, { rolesInChain: chain, listScopes: 'docs:write', listDenies: true });
3891
+ assert.sameMembers(Object.keys(all), ['rolesInChain', 'listScopes', 'listDenies']);
3892
+ assert.deepEqual(canonical(all.rolesInChain), canonical(await driver.rolesInChain(alice, chain)));
3893
+ assert.deepEqual(canonical(all.listScopes), canonical(await driver.listScopes(alice, 'docs:write')));
3894
+ assert.deepEqual(canonical(all.listDenies), canonical(await driver.listDenies(alice)));
3895
+ assert.lengthOf(all.listDenies, 2);
3896
+ assert.isAbove(all.rolesInChain.length, 0);
3897
+ // Con el deny de docs:read en la unit, listScopes de docs:read no lista la unit (ni lo que bloquea).
3898
+ const reads = await driver.readSnapshot(alice, { listScopes: 'docs:read' });
3899
+ assert.deepEqual(Object.keys(reads), ['listScopes'], 'lo no pedido no viene');
3900
+ assert.deepEqual(canonical(reads.listScopes), canonical(await driver.listScopes(alice, 'docs:read')));
3901
+ assert.deepEqual(await driver.readSnapshot(alice, { listScopes: 'no:existe' }), { listScopes: [] });
3902
+ for (const bad of [{}, { foo: true }, { listDenies: false }, { rolesInChain: 'x' }, null]) {
3903
+ await rejectsWith(assert, () => driver.readSnapshot(alice, bad), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
3904
+ }
3905
+ });
3906
+ since('2.1', snapshotDelegation
3907
+ ? 'snapshotDecisions:true · el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado'
3908
+ : 'snapshotDecisions:true · el manager compone effectivePermissions y authorizedScopes con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado (sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
3909
+ const { alice, orgA, unitA1 } = await snapshotSeed();
3910
+ const { watched: spied, touched, reads } = watchManagerCalls(driver);
3911
+ const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
3912
+ assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write']);
3913
+ assert.deepEqual(reads(), ['readSnapshot']);
3914
+ touched.length = 0;
3915
+ // docs:write: org-editor en la org y unit-editor en la unit (viewer en app solo da docs:read) ⇒ la unit.
3916
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
3917
+ assert.deepEqual(reads(), ['readSnapshot']);
3918
+ touched.length = 0;
3919
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
3920
+ assert.deepEqual(reads(), ['readSnapshot']);
3921
+ if (!snapshotDelegation)
3922
+ return;
3923
+ assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
3924
+ // La policy de delegación: el actor tiene docs:read DENEGADO en la unit (owner) ⇒ 422 con UNA foto.
3925
+ const admin = subject();
3926
+ await driver.grant(admin, 'org-admin', orgA);
3927
+ await driver.deny(admin, 'docs:read', orgA);
3928
+ touched.length = 0;
3929
+ await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
3930
+ status: 422,
3931
+ code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
3932
+ });
3933
+ assert.deepEqual(reads(), ['readSnapshot']);
3934
+ });
3935
+ },
3936
+ whenFalse: () => {
3937
+ // Rotación 2 · cierre 3b (H3 del auditor): el manager decide por la CAPACIDAD, no por la presencia del método, así
3938
+ // que esta cara NO exige que el puerto no traiga `readSnapshot` (un tercero puede tener un método con ese nombre
3939
+ // para otra cosa): exige que el manager NO lo llame. Sin `listDenies` no hay composición que juzgar y la cara
3940
+ // se queda en la declaración —lo dice su título (tester 🟡 6)—.
3941
+ if (!harness.capabilities.listDenies) {
3942
+ since('2.1', 'snapshotDecisions:false · (sin listDenies: solo la declaración) authorization.capabilities().snapshotDecisions es false y el driver no declara true; sin listDenies el manager no compone ninguna decisión con deny que juzgar', async ({ assert, }) => {
3943
+ assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
3944
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
3945
+ });
3946
+ return;
3947
+ }
3948
+ since('2.1', snapshotDelegation
3949
+ ? 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad)'
3950
+ : 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions y authorizedScopes con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad; sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
3951
+ assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
3952
+ assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
3953
+ const { alice, orgA, unitA1 } = await snapshotSeed();
3954
+ const { watched: spied, touched, reads } = watchManagerCalls(driver);
3955
+ const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
3956
+ assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write'], 'el deny de docs:read en la unit se resta');
3957
+ assert.include(reads(), 'listDenies');
3958
+ assert.notInclude(reads(), 'readSnapshot');
3959
+ touched.length = 0;
3960
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
3961
+ assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
3962
+ assert.include(reads(), 'listDenies');
3963
+ assert.notInclude(reads(), 'readSnapshot');
3964
+ if (!snapshotDelegation)
3965
+ return;
3966
+ assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
3967
+ const admin = subject();
3968
+ await driver.grant(admin, 'org-admin', orgA);
3969
+ await driver.deny(admin, 'docs:read', orgA);
3970
+ touched.length = 0;
3971
+ await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
3972
+ status: 422,
3973
+ code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
3974
+ });
3975
+ assert.include(reads(), 'listDenies');
3976
+ assert.notInclude(reads(), 'readSnapshot');
3977
+ });
3978
+ },
3979
+ });
3818
3980
  // ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
3819
3981
  caseFor('roleInheritanceNative', {
3820
3982
  whenFalse: () => {
@@ -343,6 +343,38 @@ export interface AuthorizationDriverCapabilities {
343
343
  * apaga casos. Par de caras en `runAuthorizationDriverContract`.
344
344
  */
345
345
  experimental?: boolean;
346
+ /**
347
+ * **Toda DECISIÓN del driver que combina concesión y deny se lee en UNA foto de sus hechos** (rotación 2 · cierre 3;
348
+ * linaje L12 «lector rasgado»). **Opcional y normalizada a `false`** (regla D15). Con `true` el driver promete:
349
+ * `authorize` (y cada posición de `authorizeMany`) y `listScopes` leen sus concesiones y sus denies de UN mismo estado
350
+ * —una escritura que cae «entre» no puede dar una respuesta que ningún estado real daba—, y trae `readSnapshot` para
351
+ * que el manager lea en una foto lo que compone con dos métodos del puerto (`effectivePermissions`, `authorizedScopes`,
352
+ * la policy de `defineScopedRole`/`updateScopedRole`). Lo que NO promete: la cadena del consumidor (`resolveChain`) y
353
+ * el catálogo se leen fuera de la foto (README «Limits», fila «Torn read»: «cadena frente a hechos»). `database` =
354
+ * `true` (UNA sentencia, `readFoto`); el manager usa `readSnapshot` solo con esta capacidad en `true`; `openfga` no la declara (`false`: su `Check` lee ramas del modelo por separado,
355
+ * la excepción declarada del invariante 2) y el manager compone como antes. Par de caras en
356
+ * `runAuthorizationDriverContract`.
357
+ */
358
+ snapshotDecisions?: boolean;
359
+ }
360
+ /**
361
+ * Lo que `readSnapshot` lee en UNA foto (rotación 2 · cierre 3). Cada clave pedida es la pregunta del método homónimo
362
+ * del puerto para el mismo holder: `rolesInChain` con esa cadena (ya resuelta), `listScopes` con ese permiso y
363
+ * `listDenies: true` = `listDenies(subject)` sin scope. Al menos una.
364
+ */
365
+ export interface SnapshotReads {
366
+ rolesInChain?: ScopeRef[];
367
+ listScopes?: string;
368
+ listDenies?: boolean;
369
+ }
370
+ /** La respuesta de `readSnapshot`: EXACTAMENTE la del método homónimo para cada clave pedida, todas del mismo estado; lo no pedido no viene. */
371
+ export interface SnapshotAnswer {
372
+ rolesInChain?: Array<{
373
+ scope: ScopeRef;
374
+ role: CatalogRoleRef;
375
+ }>;
376
+ listScopes?: ScopeRef[];
377
+ listDenies?: DenyRef[];
346
378
  }
347
379
  /**
348
380
  * Las claves de `AuthorizationDriverCapabilities`, LITERALES (2.4.0-alpha.4):
@@ -619,6 +651,23 @@ export interface AuthorizationDriver {
619
651
  scope: ScopeRef;
620
652
  role: CatalogRoleRef;
621
653
  }>>;
654
+ /**
655
+ * **Varias lecturas del puerto en UNA foto de los hechos** (rotación 2 · cierre 3, L12). Opcional (con su capacidad
656
+ * `snapshotDecisions`, regla D15): cada clave pedida de `reads` se responde EXACTAMENTE como el método homónimo
657
+ * (`rolesInChain(subject, chain)`, `listScopes(subject, permission)`, `listDenies(subject)`), y todas desde el MISMO
658
+ * estado de concesiones y denies. Lo usa el manager para las decisiones que compone con dos métodos del puerto:
659
+ * `effectivePermissions` y la policy de delegación (`rolesInChain` + `listDenies`) y `authorizedScopes`
660
+ * (`listScopes` + `listDenies`). El manager lo llama SOLO si el driver declara `snapshotDecisions: true` (decide la
661
+ * capacidad, no la presencia del método; rotación 2 · cierre 3b): declarar `true` sin traerlo es 500 `E_AUTHZ_CONFIG`
662
+ * —en la PRIMERA decisión compuesta, no al resolver el driver (cierre 3c, J3), nombrando la clave y la clase del
663
+ * driver—, y sin la capacidad el manager compone como antes —dos lecturas sin foto común, el hueco DECLARADO de un driver sin
664
+ * la capacidad— aunque exista un método con este nombre.
665
+ *
666
+ * `reads` mal formado (vacío, una clave desconocida, una cadena que no es array, un permiso que no es string,
667
+ * `listDenies` que no es `true`) es 422 `E_AUTHZ_INVALID_IDENTITY`; un permiso fuera del catálogo responde
668
+ * `listScopes: []`, como el método suelto.
669
+ */
670
+ readSnapshot?(subject: SubjectRef, reads: SnapshotReads): Promise<SnapshotAnswer>;
622
671
  }
623
672
  /**
624
673
  * Un subárbol excluido de un `all` (2.1, B3; tipo nominal desde 2D · F10):
@@ -59,6 +59,8 @@ export const AUTHORIZATION_DRIVER_CAPABILITY_KEYS = Object.freeze([
59
59
  'transactionalWrites',
60
60
  // Estrategia 2.5.0 · firma 4 (D15): opcional, normalizada a `false`.
61
61
  'experimental',
62
+ // Rotación 2 · cierre 3 (D15, L12): opcional, normalizada a `false`; `database` declara `true`.
63
+ 'snapshotDecisions',
62
64
  ]);
63
65
  /**
64
66
  * **La copia CONGELADA y NORMALIZADA que publica `authorization.capabilities()`**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jantstack/adonis-authz",
3
- "version": "2.5.0-alpha.4",
3
+ "version": "2.5.0-rc.1",
4
4
  "description": "Driver-based authorization engine for AdonisJS + Lucid: hierarchical scopes with downward inheritance, explicit denies, expiring assignments and polymorphic holders. Ships a self-contained database driver and an OpenFGA driver, both judged by the same executable contract suite.",
5
5
  "type": "module",
6
6
  "license": "MIT",