@jantstack/adonis-authz 2.5.0-alpha.4 → 2.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -56
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -2
- package/build/commands/authz_catalog_prune_orphans.js +28 -5
- package/build/commands/authz_scopes_relay.js +5 -1
- package/build/commands/authz_scopes_requeue.js +2 -1
- package/build/commands/authz_upgrade_1x.d.ts +216 -0
- package/build/commands/authz_upgrade_1x.js +903 -0
- package/build/commands/main.d.ts +2 -1
- package/build/commands/main.js +5 -3
- package/build/index.d.ts +2 -2
- package/build/src/catalog/catalog_cache.js +2 -1
- package/build/src/drivers/database_driver.d.ts +54 -2
- package/build/src/drivers/database_driver.js +138 -23
- package/build/src/drivers/database_relations_driver.js +25 -15
- package/build/src/errors.d.ts +39 -0
- package/build/src/errors.js +8 -0
- package/build/src/identity.d.ts +7 -1
- package/build/src/identity.js +29 -0
- package/build/src/manager.d.ts +24 -0
- package/build/src/manager.js +225 -57
- package/build/src/scope_outbox.d.ts +36 -2
- package/build/src/scope_outbox.js +78 -17
- package/build/src/shared/sql_expiry.d.ts +7 -3
- package/build/src/shared/sql_expiry.js +41 -7
- package/build/src/testing/contract.d.ts +37 -4
- package/build/src/testing/contract.js +280 -22
- package/build/src/testing/relations_contract.d.ts +11 -1
- package/build/src/testing/relations_contract.js +107 -18
- package/build/src/types.d.ts +49 -0
- package/build/src/types.js +2 -0
- package/package.json +1 -1
package/build/commands/main.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ import AuthzReconcile from './authz_reconcile.js';
|
|
|
9
9
|
import AuthzRelationsReconcile from './authz_relations_reconcile.js';
|
|
10
10
|
import AuthzFreeze from './authz_freeze.js';
|
|
11
11
|
import AuthzUnfreeze from './authz_unfreeze.js';
|
|
12
|
-
|
|
12
|
+
import AuthzUpgrade1x from './authz_upgrade_1x.js';
|
|
13
|
+
export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, AuthzUpgrade1x, };
|
|
13
14
|
/** Contrato de loader de ace (`LoadersContract.getMetaData`). */
|
|
14
15
|
export declare function getMetaData(): Promise<CommandMetaData[]>;
|
|
15
16
|
/** Contrato de loader de ace (`LoadersContract.getCommand`): la clase, o `null` si el nombre no es nuestro. */
|
package/build/commands/main.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* desenvolver `default` (mismo contrato que `@adonisjs/core/commands` y
|
|
11
11
|
* `@adonisjs/lucid/commands`, que leen un `commands.json` generado por el
|
|
12
12
|
* assembler; aquí no hay assembler, así que el loader es un `ListLoader`
|
|
13
|
-
* sobre las
|
|
13
|
+
* sobre las once clases (nueve hasta alpha.4, diez hasta la rotación 3 de 2.5) y no hay índice que generar).
|
|
14
14
|
*
|
|
15
15
|
* Hasta alpha.3 este fichero re-exportaba las nueve clases y NADA más, de
|
|
16
16
|
* modo que en un consumidor configurado con `node ace configure` **`node ace`
|
|
@@ -37,8 +37,9 @@ import AuthzReconcile from './authz_reconcile.js';
|
|
|
37
37
|
import AuthzRelationsReconcile from './authz_relations_reconcile.js';
|
|
38
38
|
import AuthzFreeze from './authz_freeze.js';
|
|
39
39
|
import AuthzUnfreeze from './authz_unfreeze.js';
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
import AuthzUpgrade1x from './authz_upgrade_1x.js';
|
|
41
|
+
export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, AuthzUpgrade1x, };
|
|
42
|
+
/** Los once (nueve hasta alpha.4; `authz:scopes:requeue` desde alpha.5; `authz:upgrade-1x` desde la rotación 3 de 2.5), en el orden en que `node ace list` los enseña. */
|
|
42
43
|
const loader = new ListLoader([
|
|
43
44
|
OpenFgaProvision,
|
|
44
45
|
AuthzCatalogDiff,
|
|
@@ -50,6 +51,7 @@ const loader = new ListLoader([
|
|
|
50
51
|
AuthzRelationsReconcile,
|
|
51
52
|
AuthzFreeze,
|
|
52
53
|
AuthzUnfreeze,
|
|
54
|
+
AuthzUpgrade1x,
|
|
53
55
|
]);
|
|
54
56
|
/** Contrato de loader de ace (`LoadersContract.getMetaData`). */
|
|
55
57
|
export function getMetaData() {
|
package/build/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* import { AuthorizationManager, APP_SCOPE } from '@jantstack/adonis-authz'
|
|
4
4
|
*/
|
|
5
5
|
export { AuthorizationManager, DEFAULT_MAX_SCOPES, DEFAULT_MAX_DESCENDANTS, MAX_SCOPE_BOUND, DEFAULT_VIEW_MAX_AGE_MS, PARKED_CENSUS_MAX, RELAY_ORIGIN_MAX_HOPS, RELAY_LEASE_POLL_MS, expandExcludedSubtrees } from './src/manager.js';
|
|
6
|
-
export type { AuthorizationView, ForRequestOptions } from './src/manager.js';
|
|
6
|
+
export type { AuthorizationView, ForRequestOptions, MassPurgeReason } from './src/manager.js';
|
|
7
7
|
export { defineConfig } from './src/define_config.js';
|
|
8
8
|
export type { AuthorizationConfig } from './src/define_config.js';
|
|
9
9
|
export { syncAuthzCatalog, diffAuthzCatalog, catalogInSync, formatCatalogDiff, formatScopedRoles, formatShadowedRoles, runCatalogDiff, syncCatalogs, } from './src/catalog/catalog.js';
|
|
@@ -89,7 +89,7 @@ export type { DatabaseDriverOptions } from './src/drivers/database_driver.js';
|
|
|
89
89
|
* opcional `@openfga/sdk`. Un consumidor solo-database arranca sin él (D9).
|
|
90
90
|
*/
|
|
91
91
|
export { APP_SCOPE, APP_SCOPE_TYPE, DEFAULT_RECONCILE_MAX_TUPLES } from './src/types.js';
|
|
92
|
-
export type { AuthorizationDriver, AuthorizationDriverFactory, AuthorizedScopes, AuthzWriteEvent, AuthzCatalogWriteEvent, CatalogPermissionSpec, CatalogRole, CatalogRoleSpec, CatalogSpec, ScopedRoleChanges, ScopedRoleSpec, DenyOptions, DenyRef, ExcludedSubtree, GrantOptions, GrantOutcome, HolderTypeMap, ReconcileCounts, ReconcileFact, ReconcileFactPage, ReconcileFactsEnumerator, ReconcileOptions, ReconcileReport, ReconcileSkip, ReconcileSource, RoleQuery, ScopeChainResolver, ScopeDescendantsResolver, ScopeEdge, ScopeEdgePage, ScopeEdgesEnumerator, ScopedWriteOptions, ScopeRef, ScopeType, SubjectRef, WriteOptions, AuthorizationDriverCapabilities, RelationsDriver, RelationsDriverCapabilities, RelObject, RelSubject, RelUserset, RelationRef, RelationWriteEvent, RelationWriteOptions, RelationDenyOptions, RelationDecision, RelationTransactionOptions, RelationPurgeOptions, RelationPage, RelationObjectsPage, RelationSubjectsPage, RelationTuple, RelationTuplePage, ScopeOutbox, ScopeOutboxContext, ScopeOutboxLease, ScopeOutboxPendingFilter, ScopeOutboxFailure, ScopeRequeueConflict, ScopeRequeueReport, ScopeTreeChange, PendingScopeTreeChange, RelayedScopeChange, ScopeRelayReport, ScopeRelayFailure, ScopeRelayEntry, ScopeRelayEntryStatus, ScopeEnqueueReceipt, } from './src/types.js';
|
|
92
|
+
export type { AuthorizationDriver, AuthorizationDriverFactory, AuthorizedScopes, AuthzWriteEvent, AuthzCatalogWriteEvent, CatalogPermissionSpec, CatalogRole, CatalogRoleSpec, CatalogSpec, ScopedRoleChanges, ScopedRoleSpec, DenyOptions, DenyRef, ExcludedSubtree, GrantOptions, GrantOutcome, HolderTypeMap, ReconcileCounts, ReconcileFact, ReconcileFactPage, ReconcileFactsEnumerator, ReconcileOptions, ReconcileReport, ReconcileSkip, ReconcileSource, RoleQuery, ScopeChainResolver, ScopeDescendantsResolver, ScopeEdge, ScopeEdgePage, ScopeEdgesEnumerator, ScopedWriteOptions, ScopeRef, ScopeType, SnapshotAnswer, SnapshotReads, SubjectRef, WriteOptions, AuthorizationDriverCapabilities, RelationsDriver, RelationsDriverCapabilities, RelObject, RelSubject, RelUserset, RelationRef, RelationWriteEvent, RelationWriteOptions, RelationDenyOptions, RelationDecision, RelationTransactionOptions, RelationPurgeOptions, RelationPage, RelationObjectsPage, RelationSubjectsPage, RelationTuple, RelationTuplePage, ScopeOutbox, ScopeOutboxContext, ScopeOutboxLease, ScopeOutboxPendingFilter, ScopeOutboxFailure, ScopeRequeueConflict, ScopeRequeueReport, ScopeTreeChange, PendingScopeTreeChange, RelayedScopeChange, ScopeRelayReport, ScopeRelayFailure, ScopeRelayEntry, ScopeRelayEntryStatus, ScopeEnqueueReceipt, } from './src/types.js';
|
|
93
93
|
export { isRelUserset } from './src/types.js';
|
|
94
94
|
/**
|
|
95
95
|
* **¿De quién es esta transacción?** (L-1 · 🟠 9, regla del puerto `{trx}`):
|
|
@@ -240,7 +240,8 @@ export async function readLocalRoles(options = {}) {
|
|
|
240
240
|
.select('uuid', 'slug', 'scope_type', 'rank', 'owner_scope_key'));
|
|
241
241
|
if (rows.length > maxLocalRoles) {
|
|
242
242
|
throw new TooManyLocalRolesError(`El catálogo tiene más de ${maxLocalRoles} roles locales (cota 'maxLocalRoles'). No se devuelve una lista ` +
|
|
243
|
-
`parcial: quien lee esto decide qué se BORRA. Sube la cota a sabiendas si tu catálogo local es así de grande
|
|
243
|
+
`parcial: quien lee esto decide qué se BORRA. Sube la cota a sabiendas si tu catálogo local es así de grande: ` +
|
|
244
|
+
`pruneOrphanRoles({ maxLocalRoles }) o --max-local-roles en authz:catalog:prune-orphans.`);
|
|
244
245
|
}
|
|
245
246
|
if (rows.length === 0)
|
|
246
247
|
return [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import db from '@adonisjs/lucid/services/db';
|
|
2
|
-
import type { AuthorizationDriver, AuthorizationDriverCapabilities, CatalogRoleRef, DenyRef, GrantOptions, GrantOutcome, NormalizedRoleQuery, ReconcileOptions, ReconcileReport, ReconcileSource, RoleQuery, ScopeRef, ScopeType, SubjectRef, WriteOptions } from '../types.js';
|
|
2
|
+
import type { AuthorizationDriver, AuthorizationDriverCapabilities, CatalogRoleRef, DenyRef, GrantOptions, GrantOutcome, NormalizedRoleQuery, ReconcileOptions, ReconcileReport, ReconcileSource, RoleQuery, ScopeRef, ScopeType, SnapshotAnswer, SnapshotReads, SubjectRef, WriteOptions } from '../types.js';
|
|
3
3
|
import type { ScopeChainResolver } from '../types.js';
|
|
4
4
|
import { CatalogCache } from '../catalog/catalog_cache.js';
|
|
5
5
|
import type { CatalogRevalidate, CatalogRole, CatalogView } from '../catalog/catalog_cache.js';
|
|
@@ -168,7 +168,7 @@ export declare class DatabaseAuthorizationDriver implements AuthorizationDriver
|
|
|
168
168
|
/**
|
|
169
169
|
* Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
|
|
170
170
|
* (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
|
|
171
|
-
* mitigar; `authorize`
|
|
171
|
+
* mitigar; `authorize` es UNA consulta SQL de hechos (la foto, rotación 2 · cierre 3) más el catálogo y el árbol, no un `Check`; la membresía la resuelve
|
|
172
172
|
* el propio SQL contra la cadena que le pasan; los `list*` no enumeran
|
|
173
173
|
* herencia (invariante 7); y `purgeRole` está implementado en una
|
|
174
174
|
* transacción.
|
|
@@ -248,6 +248,33 @@ export declare class DatabaseAuthorizationDriver implements AuthorizationDriver
|
|
|
248
248
|
*/
|
|
249
249
|
private sql;
|
|
250
250
|
private first;
|
|
251
|
+
/**
|
|
252
|
+
* **La FOTO de una decisión: el punto de paso ÚNICO** (rotación 2 · cierre 3; 4.ª aparición del linaje L12 «lector
|
|
253
|
+
* rasgado», la primera en `database`). Propiedad universal: *toda DECISIÓN de `database` que combina concesión y deny
|
|
254
|
+
* se lee en UNA foto*. Hasta aquí `authorize` leía los denies y las asignaciones en DOS sentencias, y una escritura
|
|
255
|
+
* que caía entre las dos daba una respuesta que NINGÚN estado real daba (un `deny` y después un `grant` ⇒ `true` una
|
|
256
|
+
* vez; repro `.claude/reproducciones/database-authorize-rasgado.spec.ts`).
|
|
257
|
+
*
|
|
258
|
+
* La foto es **UNA sentencia**: las partes van unidas con `UNION ALL` y cada fila dice de qué parte es
|
|
259
|
+
* (`foto_kind`). Una sentencia ve UN estado en los tres motores y en cualquier aislamiento —PostgreSQL toma la foto
|
|
260
|
+
* por SENTENCIA en READ COMMITTED, InnoDB una vista de lectura por sentencia, SQLite una transacción de lectura
|
|
261
|
+
* implícita—, así que no hace falta abrir una transacción REPEATABLE READ como `withSourceSnapshot` del reconcile:
|
|
262
|
+
* eso costaría BEGIN/COMMIT por pregunta en el camino caliente y, con un pool de 1 (`:memory:`), retendría la única
|
|
263
|
+
* conexión mientras el catálogo o un `resolveChain` SQL del consumidor esperan otra. Elegido por eso: una ida y
|
|
264
|
+
* vuelta (antes dos), sin conexión retenida y sin nivel por motor.
|
|
265
|
+
*
|
|
266
|
+
* Lo que va DENTRO de la foto: los HECHOS (asignaciones —con el vínculo rol→permiso por `JOIN` cuando la parte lo
|
|
267
|
+
* filtra— y denies). Lo que va FUERA, antes o después: el catálogo (memo contrastado con la versión compartida,
|
|
268
|
+
* invariante 14) y la cadena del consumidor (`resolveChain`). «Cadena frente a hechos» (un `scopes.moved` entre el
|
|
269
|
+
* `resolveChain` y la foto) depende del resolutor del consumidor y está DECLARADO en README «Limits» (fila «Torn
|
|
270
|
+
* read»), no se arregla aquí.
|
|
271
|
+
*
|
|
272
|
+
* Columnas comunes de cada parte: `foto_kind`, `foto_scope_type`, `foto_scope_uuid`, `foto_role` y
|
|
273
|
+
* `foto_permission` (`null` en la parte que no la tiene). Solo SQL estándar del query builder (M6). Una sola parte
|
|
274
|
+
* es una sola `SELECT`, sin `UNION`. La guardia `database.decision.snapshot` cuenta las sentencias que tocan
|
|
275
|
+
* `authz_assignments`/`authz_denies` por decisión: con dos, rojo.
|
|
276
|
+
*/
|
|
277
|
+
private readFoto;
|
|
251
278
|
/**
|
|
252
279
|
* Por dónde escribe `operation` (L-3): la transacción ABIERTA del llamante
|
|
253
280
|
* si llegó en `options.transaction` —validada contra la conexión de este
|
|
@@ -316,16 +343,41 @@ export declare class DatabaseAuthorizationDriver implements AuthorizationDriver
|
|
|
316
343
|
scope: ScopeRef;
|
|
317
344
|
role: CatalogRoleRef;
|
|
318
345
|
}>>;
|
|
346
|
+
/** La parte de la foto de `rolesInChain`: asignaciones vigentes del holder en los scopes de la cadena (`null` sin cadena). */
|
|
347
|
+
private rolesInChainPart;
|
|
348
|
+
/** De las filas de la foto a la respuesta de `rolesInChain` (catálogo DESPUÉS de la foto: es memo, no hechos). */
|
|
349
|
+
private rolesInChainFrom;
|
|
319
350
|
listRoleScopes(subject: SubjectRef, scopeType: ScopeType): Promise<ScopeRef[]>;
|
|
320
351
|
/** ¿El rol (por uuid) existe en el scope cuya cadena tiene esas claves? (3B · B2, `declaredRoleAt`) */
|
|
321
352
|
private visibleAt;
|
|
322
353
|
listScopes(subject: SubjectRef, permission: string): Promise<ScopeRef[]>;
|
|
354
|
+
/** La parte «asignaciones vigentes del holder cuyo rol concede el permiso», en todo scope (la de `listScopes`). */
|
|
355
|
+
private grantsOfPart;
|
|
356
|
+
/** La parte «denies del holder» (de UN permiso si se da; si no, todos), en todo scope. */
|
|
357
|
+
private deniesPart;
|
|
358
|
+
/**
|
|
359
|
+
* De la foto a la respuesta de `listScopes` (árbol y catálogo DESPUÉS de la foto). `denies` son los del permiso: por
|
|
360
|
+
* scope, el conjunto de roles que conceden; el scope se lista si el árbol lo conoce, alguno de esos roles existe ahí
|
|
361
|
+
* (3B · B2) y ningún deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
|
|
362
|
+
*/
|
|
363
|
+
private listScopesFrom;
|
|
323
364
|
/**
|
|
324
365
|
* Denies directos del holder (2.1, B5): del scope exacto o todos. Solo los
|
|
325
366
|
* de permisos que el catálogo declara (memo); los de scopes que el árbol
|
|
326
367
|
* no conoce no se listan (D8), como en `listRoleScopes`.
|
|
327
368
|
*/
|
|
328
369
|
listDenies(subject: SubjectRef, scope?: ScopeRef): Promise<DenyRef[]>;
|
|
370
|
+
/** De las filas de denies a `DenyRef[]` (catálogo y, sin `scope` pedido, árbol DESPUÉS de la foto). */
|
|
371
|
+
private deniesFrom;
|
|
372
|
+
/**
|
|
373
|
+
* **Varias lecturas del puerto en UNA foto** (rotación 2 · cierre 3, L12; método OPCIONAL del puerto,
|
|
374
|
+
* `AuthorizationDriver.readSnapshot`, capacidad `snapshotDecisions`). Es lo que el manager usa para las decisiones
|
|
375
|
+
* que COMPONE con dos métodos del puerto —`effectivePermissions` y la policy de delegación (`rolesInChain` +
|
|
376
|
+
* `listDenies`), `authorizedScopes` (`listScopes` + `listDenies`)—: cada respuesta pedida es EXACTAMENTE la del método
|
|
377
|
+
* homónimo, y todas salen de la MISMA foto de los hechos (`readFoto`: una sentencia). Lo no pedido no viene.
|
|
378
|
+
* Árbol y catálogo se leen después de la foto, como en cada método suelto.
|
|
379
|
+
*/
|
|
380
|
+
readSnapshot(subject: SubjectRef, reads: SnapshotReads): Promise<SnapshotAnswer>;
|
|
329
381
|
/**
|
|
330
382
|
* Borra asignaciones y denies del scope exacto, en una transacción. Si el
|
|
331
383
|
* árbol aún lo conoce se purga su identidad canónica (K1); si ya no (el
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import db from '@adonisjs/lucid/services/db';
|
|
2
2
|
import { v7 as uuidv7 } from 'uuid';
|
|
3
3
|
import { APP_SCOPE_TYPE } from '../types.js';
|
|
4
|
-
import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
|
|
4
|
+
import { assertCatalogUuid, assertIdentity, assertScope, assertSnapshotReads, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
|
|
5
5
|
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
6
6
|
import { AuthorizationBackendError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, RoleNotVisibleError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
7
7
|
import { RECONCILE_MAX_DETAILS, emptyReconcilePhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases, } from '../reconcile.js';
|
|
@@ -243,6 +243,12 @@ export function whereScopeIn(query, column, scopes, intent) {
|
|
|
243
243
|
}
|
|
244
244
|
});
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Las partes posibles de la FOTO de una decisión (rotación 2 · cierre 3, `readFoto`): `grant` = asignaciones que
|
|
248
|
+
* conceden UN permiso (con el vínculo rol→permiso por `JOIN`), `role` = asignaciones sin filtrar por permiso
|
|
249
|
+
* (`rolesInChain`) y `deny` = denies. El literal de `foto_kind` sale de esta tabla CERRADA, jamás de un dato.
|
|
250
|
+
*/
|
|
251
|
+
const FOTO_KINDS = { grant: 'grant', deny: 'deny', role: 'role' };
|
|
246
252
|
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
247
253
|
/**
|
|
248
254
|
* Driver `database`: motor de autorización propio sobre las tablas `authz_*`.
|
|
@@ -262,7 +268,7 @@ export class DatabaseAuthorizationDriver {
|
|
|
262
268
|
/**
|
|
263
269
|
* Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
|
|
264
270
|
* (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
|
|
265
|
-
* mitigar; `authorize`
|
|
271
|
+
* mitigar; `authorize` es UNA consulta SQL de hechos (la foto, rotación 2 · cierre 3) más el catálogo y el árbol, no un `Check`; la membresía la resuelve
|
|
266
272
|
* el propio SQL contra la cadena que le pasan; los `list*` no enumeran
|
|
267
273
|
* herencia (invariante 7); y `purgeRole` está implementado en una
|
|
268
274
|
* transacción.
|
|
@@ -330,6 +336,9 @@ export class DatabaseAuthorizationDriver {
|
|
|
330
336
|
// Estrategia 2.5.0 · firma 4 (D15): `false` EXPLÍCITO — `database` es el driver ESTABLE de 2.5.0 (el experimental
|
|
331
337
|
// es `openfga`). Explícito para que la copia de `authorization.capabilities()` y este objeto digan lo mismo.
|
|
332
338
|
experimental: false,
|
|
339
|
+
// Rotación 2 · cierre 3 (D15, L12): toda decisión que combina concesión y deny se lee en UNA sentencia
|
|
340
|
+
// (`readFoto`), y `readSnapshot` da esa foto a las composiciones del manager.
|
|
341
|
+
snapshotDecisions: true,
|
|
333
342
|
});
|
|
334
343
|
assertCatalogOptions('DatabaseAuthorizationDriver', options);
|
|
335
344
|
this.catalog =
|
|
@@ -407,6 +416,54 @@ export class DatabaseAuthorizationDriver {
|
|
|
407
416
|
const rows = await this.sql(operation, () => fn().limit(1));
|
|
408
417
|
return rows[0] ?? null;
|
|
409
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* **La FOTO de una decisión: el punto de paso ÚNICO** (rotación 2 · cierre 3; 4.ª aparición del linaje L12 «lector
|
|
421
|
+
* rasgado», la primera en `database`). Propiedad universal: *toda DECISIÓN de `database` que combina concesión y deny
|
|
422
|
+
* se lee en UNA foto*. Hasta aquí `authorize` leía los denies y las asignaciones en DOS sentencias, y una escritura
|
|
423
|
+
* que caía entre las dos daba una respuesta que NINGÚN estado real daba (un `deny` y después un `grant` ⇒ `true` una
|
|
424
|
+
* vez; repro `.claude/reproducciones/database-authorize-rasgado.spec.ts`).
|
|
425
|
+
*
|
|
426
|
+
* La foto es **UNA sentencia**: las partes van unidas con `UNION ALL` y cada fila dice de qué parte es
|
|
427
|
+
* (`foto_kind`). Una sentencia ve UN estado en los tres motores y en cualquier aislamiento —PostgreSQL toma la foto
|
|
428
|
+
* por SENTENCIA en READ COMMITTED, InnoDB una vista de lectura por sentencia, SQLite una transacción de lectura
|
|
429
|
+
* implícita—, así que no hace falta abrir una transacción REPEATABLE READ como `withSourceSnapshot` del reconcile:
|
|
430
|
+
* eso costaría BEGIN/COMMIT por pregunta en el camino caliente y, con un pool de 1 (`:memory:`), retendría la única
|
|
431
|
+
* conexión mientras el catálogo o un `resolveChain` SQL del consumidor esperan otra. Elegido por eso: una ida y
|
|
432
|
+
* vuelta (antes dos), sin conexión retenida y sin nivel por motor.
|
|
433
|
+
*
|
|
434
|
+
* Lo que va DENTRO de la foto: los HECHOS (asignaciones —con el vínculo rol→permiso por `JOIN` cuando la parte lo
|
|
435
|
+
* filtra— y denies). Lo que va FUERA, antes o después: el catálogo (memo contrastado con la versión compartida,
|
|
436
|
+
* invariante 14) y la cadena del consumidor (`resolveChain`). «Cadena frente a hechos» (un `scopes.moved` entre el
|
|
437
|
+
* `resolveChain` y la foto) depende del resolutor del consumidor y está DECLARADO en README «Limits» (fila «Torn
|
|
438
|
+
* read»), no se arregla aquí.
|
|
439
|
+
*
|
|
440
|
+
* Columnas comunes de cada parte: `foto_kind`, `foto_scope_type`, `foto_scope_uuid`, `foto_role` y
|
|
441
|
+
* `foto_permission` (`null` en la parte que no la tiene). Solo SQL estándar del query builder (M6). Una sola parte
|
|
442
|
+
* es una sola `SELECT`, sin `UNION`. La guardia `database.decision.snapshot` cuenta las sentencias que tocan
|
|
443
|
+
* `authz_assignments`/`authz_denies` por decisión: con dos, rojo.
|
|
444
|
+
*/
|
|
445
|
+
async readFoto(operation, parts) {
|
|
446
|
+
const foto = { grant: [], deny: [], role: [] };
|
|
447
|
+
const live = parts.filter((part) => part !== null);
|
|
448
|
+
if (live.length === 0)
|
|
449
|
+
return foto;
|
|
450
|
+
const selects = live.map((part) => part.query.select(db.raw(`'${FOTO_KINDS[part.kind]}' as foto_kind`), `${part.scope}_type as foto_scope_type`, `${part.scope}_uuid as foto_scope_uuid`, part.role ? `${part.role} as foto_role` : db.raw('null as foto_role'), part.permission ? `${part.permission} as foto_permission` : db.raw('null as foto_permission')));
|
|
451
|
+
const [head, ...rest] = selects;
|
|
452
|
+
const rows = await this.sql(operation, () => (rest.length ? head.unionAll(rest) : head));
|
|
453
|
+
for (const row of rows) {
|
|
454
|
+
const kind = row.foto_kind;
|
|
455
|
+
if (!(kind in foto)) {
|
|
456
|
+
throw new AuthorizationInternalError(`database.${operation}: la foto devolvió una fila de una parte desconocida (${String(kind)})`);
|
|
457
|
+
}
|
|
458
|
+
foto[kind].push({
|
|
459
|
+
scope_type: row.foto_scope_type,
|
|
460
|
+
scope_uuid: row.foto_scope_uuid,
|
|
461
|
+
role_uuid: row.foto_role ?? null,
|
|
462
|
+
permission_uuid: row.foto_permission ?? null,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return foto;
|
|
466
|
+
}
|
|
410
467
|
/**
|
|
411
468
|
* Por dónde escribe `operation` (L-3): la transacción ABIERTA del llamante
|
|
412
469
|
* si llegó en `options.transaction` —validada contra la conexión de este
|
|
@@ -511,8 +568,6 @@ export class DatabaseAuthorizationDriver {
|
|
|
511
568
|
.where('permission_uuid', perm.uuid), 'scope', chain, 'read');
|
|
512
569
|
if (!deniedQuery)
|
|
513
570
|
return false;
|
|
514
|
-
if (await this.first('authorize.denies', () => deniedQuery))
|
|
515
|
-
return false;
|
|
516
571
|
// Una asignación vigente, en la cadena, cuyo rol concede el permiso Y es
|
|
517
572
|
// visible en su nivel (global u owner ancestro-o-igual del scope de la
|
|
518
573
|
// asignación, 3B · B2). `assignableAt`/`rank` no entran: composición.
|
|
@@ -525,8 +580,13 @@ export class DatabaseAuthorizationDriver {
|
|
|
525
580
|
.where('a.holder_uuid', subject.uuid), chain);
|
|
526
581
|
if (!grantedQuery)
|
|
527
582
|
return false;
|
|
528
|
-
|
|
529
|
-
|
|
583
|
+
// Los denies y las asignaciones en UNA foto (rotación 2 · cierre 3, L12): deny en la cadena ⇒ false; si no, una
|
|
584
|
+
// asignación vigente que concede ⇒ true. Nunca dos sentencias: ver `readFoto`.
|
|
585
|
+
const foto = await this.readFoto('authorize', [
|
|
586
|
+
{ kind: 'deny', query: deniedQuery, scope: 'scope', permission: 'permission_uuid' },
|
|
587
|
+
{ kind: 'grant', query: this.whereActive(grantedQuery, 'a.expires_at'), scope: 'a.scope', role: 'a.role_uuid' },
|
|
588
|
+
]);
|
|
589
|
+
return foto.deny.length === 0 && foto.grant.length > 0;
|
|
530
590
|
}
|
|
531
591
|
async grant(subject, role, scope, options = {}) {
|
|
532
592
|
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
@@ -784,13 +844,21 @@ export class DatabaseAuthorizationDriver {
|
|
|
784
844
|
assertIdentity({ subject });
|
|
785
845
|
for (const scope of chain)
|
|
786
846
|
assertScope(scope);
|
|
847
|
+
const foto = await this.readFoto('rolesInChain', [this.rolesInChainPart(subject, chain)]);
|
|
848
|
+
return this.rolesInChainFrom(foto.role, chain);
|
|
849
|
+
}
|
|
850
|
+
/** La parte de la foto de `rolesInChain`: asignaciones vigentes del holder en los scopes de la cadena (`null` sin cadena). */
|
|
851
|
+
rolesInChainPart(subject, chain) {
|
|
787
852
|
const query = whereScopeIn(db
|
|
788
853
|
.from('authz_assignments as a')
|
|
789
854
|
.where('a.holder_type', subject.type)
|
|
790
855
|
.where('a.holder_uuid', subject.uuid), 'a.scope', chain, 'read');
|
|
791
856
|
if (!query)
|
|
792
|
-
return
|
|
793
|
-
|
|
857
|
+
return null;
|
|
858
|
+
return { kind: 'role', query: this.whereActive(query, 'a.expires_at'), scope: 'a.scope', role: 'a.role_uuid' };
|
|
859
|
+
}
|
|
860
|
+
/** De las filas de la foto a la respuesta de `rolesInChain` (catálogo DESPUÉS de la foto: es memo, no hechos). */
|
|
861
|
+
async rolesInChainFrom(rows, chain) {
|
|
794
862
|
const catalog = await this.catalog.view();
|
|
795
863
|
const keysFrom = chainKeysFrom(chain);
|
|
796
864
|
const levelOf = new Map(chain.map((s, i) => [dbScopeKey(s), i]));
|
|
@@ -853,25 +921,38 @@ export class DatabaseAuthorizationDriver {
|
|
|
853
921
|
const perm = await this.findPermission(permission);
|
|
854
922
|
if (!perm)
|
|
855
923
|
return [];
|
|
856
|
-
|
|
924
|
+
// Las asignaciones que conceden y los denies del permiso en UNA foto (rotación 2 · cierre 3, L12): antes eran dos
|
|
925
|
+
// sentencias y un offboarding (revoke, luego removeDeny) entre ellas listaba un scope que ningún estado concedía.
|
|
926
|
+
const foto = await this.readFoto('listScopes', [this.grantsOfPart(subject, perm.uuid), this.deniesPart(subject, perm.uuid)]);
|
|
927
|
+
return this.listScopesFrom(foto.grant, foto.deny);
|
|
928
|
+
}
|
|
929
|
+
/** La parte «asignaciones vigentes del holder cuyo rol concede el permiso», en todo scope (la de `listScopes`). */
|
|
930
|
+
grantsOfPart(subject, permissionUuid) {
|
|
931
|
+
const query = this.whereActive(db
|
|
857
932
|
.from('authz_assignments as a')
|
|
858
933
|
.join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
|
|
859
|
-
.where('rp.permission_uuid',
|
|
934
|
+
.where('rp.permission_uuid', permissionUuid)
|
|
860
935
|
.where('a.holder_type', subject.type)
|
|
861
|
-
.where('a.holder_uuid', subject.uuid), 'a.expires_at')
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
936
|
+
.where('a.holder_uuid', subject.uuid), 'a.expires_at');
|
|
937
|
+
return { kind: 'grant', query, scope: 'a.scope', role: 'a.role_uuid', permission: 'rp.permission_uuid' };
|
|
938
|
+
}
|
|
939
|
+
/** La parte «denies del holder» (de UN permiso si se da; si no, todos), en todo scope. */
|
|
940
|
+
deniesPart(subject, permissionUuid) {
|
|
941
|
+
const query = db.from('authz_denies').where('holder_type', subject.type).where('holder_uuid', subject.uuid);
|
|
942
|
+
if (permissionUuid !== undefined)
|
|
943
|
+
query.where('permission_uuid', permissionUuid);
|
|
944
|
+
return { kind: 'deny', query, scope: 'scope', permission: 'permission_uuid' };
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* De la foto a la respuesta de `listScopes` (árbol y catálogo DESPUÉS de la foto). `denies` son los del permiso: por
|
|
948
|
+
* scope, el conjunto de roles que conceden; el scope se lista si el árbol lo conoce, alguno de esos roles existe ahí
|
|
949
|
+
* (3B · B2) y ningún deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
|
|
950
|
+
*/
|
|
951
|
+
async listScopesFrom(grants, denies) {
|
|
868
952
|
const deniedKeys = new Set(denies.map((d) => `${d.scope_type}:${fromDbScopeUuid(d.scope_uuid) ?? ''}`));
|
|
869
|
-
// Por scope: el conjunto de roles que conceden; el scope se lista si el
|
|
870
|
-
// árbol lo conoce, alguno de esos roles existe ahí (3B · B2) y ningún
|
|
871
|
-
// deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
|
|
872
953
|
const catalog = await this.catalog.view();
|
|
873
954
|
const byScope = new Map();
|
|
874
|
-
for (const row of
|
|
955
|
+
for (const row of grants) {
|
|
875
956
|
const k = rowScopeKey(row);
|
|
876
957
|
if (!byScope.has(k))
|
|
877
958
|
byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
|
|
@@ -910,7 +991,11 @@ export class DatabaseAuthorizationDriver {
|
|
|
910
991
|
const query = chain ? whereScopeIn(base, 'scope', [chain[0]], 'read') : base;
|
|
911
992
|
if (!query)
|
|
912
993
|
return [];
|
|
913
|
-
const
|
|
994
|
+
const foto = await this.readFoto('listDenies', [{ kind: 'deny', query, scope: 'scope', permission: 'permission_uuid' }]);
|
|
995
|
+
return this.deniesFrom(foto.deny, scope === undefined);
|
|
996
|
+
}
|
|
997
|
+
/** De las filas de denies a `DenyRef[]` (catálogo y, sin `scope` pedido, árbol DESPUÉS de la foto). */
|
|
998
|
+
async deniesFrom(rows, resolveEach) {
|
|
914
999
|
const view = await this.catalog.view();
|
|
915
1000
|
const result = [];
|
|
916
1001
|
for (const row of rows) {
|
|
@@ -918,12 +1003,42 @@ export class DatabaseAuthorizationDriver {
|
|
|
918
1003
|
if (!permission)
|
|
919
1004
|
continue;
|
|
920
1005
|
const denyScope = { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) };
|
|
921
|
-
if (
|
|
1006
|
+
if (resolveEach && !(await this.chain(denyScope, 'listDenies')))
|
|
922
1007
|
continue;
|
|
923
1008
|
result.push({ permission, scope: denyScope });
|
|
924
1009
|
}
|
|
925
1010
|
return result;
|
|
926
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* **Varias lecturas del puerto en UNA foto** (rotación 2 · cierre 3, L12; método OPCIONAL del puerto,
|
|
1014
|
+
* `AuthorizationDriver.readSnapshot`, capacidad `snapshotDecisions`). Es lo que el manager usa para las decisiones
|
|
1015
|
+
* que COMPONE con dos métodos del puerto —`effectivePermissions` y la policy de delegación (`rolesInChain` +
|
|
1016
|
+
* `listDenies`), `authorizedScopes` (`listScopes` + `listDenies`)—: cada respuesta pedida es EXACTAMENTE la del método
|
|
1017
|
+
* homónimo, y todas salen de la MISMA foto de los hechos (`readFoto`: una sentencia). Lo no pedido no viene.
|
|
1018
|
+
* Árbol y catálogo se leen después de la foto, como en cada método suelto.
|
|
1019
|
+
*/
|
|
1020
|
+
async readSnapshot(subject, reads) {
|
|
1021
|
+
assertIdentity({ subject });
|
|
1022
|
+
const asked = assertSnapshotReads(reads);
|
|
1023
|
+
const chain = asked.rolesInChain;
|
|
1024
|
+
const perm = asked.listScopes !== undefined ? await this.findPermission(asked.listScopes) : null;
|
|
1025
|
+
// Los denies: todos si se pidió `listDenies`; si solo hace falta para `listScopes`, los de ese permiso.
|
|
1026
|
+
const wantsDenies = asked.listDenies === true || perm !== null;
|
|
1027
|
+
const foto = await this.readFoto('readSnapshot', [
|
|
1028
|
+
chain ? this.rolesInChainPart(subject, chain) : null,
|
|
1029
|
+
perm ? this.grantsOfPart(subject, perm.uuid) : null,
|
|
1030
|
+
wantsDenies ? this.deniesPart(subject, asked.listDenies === true ? undefined : perm.uuid) : null,
|
|
1031
|
+
]);
|
|
1032
|
+
const answer = {};
|
|
1033
|
+
if (chain)
|
|
1034
|
+
answer.rolesInChain = await this.rolesInChainFrom(foto.role, chain);
|
|
1035
|
+
if (asked.listScopes !== undefined) {
|
|
1036
|
+
answer.listScopes = perm ? await this.listScopesFrom(foto.grant, foto.deny.filter((d) => d.permission_uuid === perm.uuid)) : [];
|
|
1037
|
+
}
|
|
1038
|
+
if (asked.listDenies === true)
|
|
1039
|
+
answer.listDenies = await this.deniesFrom(foto.deny, true);
|
|
1040
|
+
return answer;
|
|
1041
|
+
}
|
|
927
1042
|
/**
|
|
928
1043
|
* Borra asignaciones y denies del scope exacto, en una transacción. Si el
|
|
929
1044
|
* árbol aún lo conoce se purga su identidad canónica (K1); si ya no (el
|
|
@@ -760,6 +760,11 @@ export class DatabaseRelationsDriver {
|
|
|
760
760
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
761
761
|
const at = this.#expiry.bind(this.#now());
|
|
762
762
|
const active = this.#activeSql(q, 'r');
|
|
763
|
+
// Rotación 2 · cierre 3b (L12, H1 del auditor): la PUERTA «¿hay algún `denied_*` vigente en el objeto?» va en la
|
|
764
|
+
// MISMA sentencia que los candidatos (una rama más del `UNION`, `foto_kind = 'd'`). Antes era una segunda sentencia y
|
|
765
|
+
// un offboarding suelto (unrelate y luego removeDeny) que caía entre las dos devolvía los candidatos de la primera
|
|
766
|
+
// SIN juzgarlos: listaba a un sujeto que no era miembro en NINGÚN estado real.
|
|
767
|
+
const deniedRelations = deniableAbove(this.#config, object.type, relation).map(deniedRelationOf);
|
|
763
768
|
// `grp`: los grupos alcanzables por usersets desde (object, RS) —directos y
|
|
764
769
|
// anidados—. Ancla de UN solo SELECT (MySQL no mezcla UNION ALL con la
|
|
765
770
|
// recursión): los usersets directos del objeto para cualquier rel de RS.
|
|
@@ -777,16 +782,22 @@ export class DatabaseRelationsDriver {
|
|
|
777
782
|
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
778
783
|
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
779
784
|
`) ` +
|
|
780
|
-
// Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp
|
|
781
|
-
|
|
785
|
+
// Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp` (`foto_kind = 'm'`),
|
|
786
|
+
// UNION la puerta de denies (`foto_kind = 'd'`, una fila como mucho: el `UNION` funde los `NULL`).
|
|
787
|
+
`SELECT ${meta.hint}'m' AS ${q('foto_kind')}, ${q('subject_type')} AS ${q('subject_type')}, ${q('subject_uuid')} AS ${q('subject_uuid')} ` +
|
|
782
788
|
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
783
789
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
784
790
|
`AND r.${q('relation')} IN (${relPlaceholders}) AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active} ` +
|
|
785
791
|
`UNION ` +
|
|
786
|
-
`SELECT r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
792
|
+
`SELECT 'm', r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
787
793
|
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
788
794
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
789
|
-
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}
|
|
795
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}` +
|
|
796
|
+
(deniedRelations.length
|
|
797
|
+
? ` UNION SELECT 'd', NULL, NULL FROM ${q(RELATIONS_TABLE)} r ` +
|
|
798
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
799
|
+
`AND r.${q('relation')} IN (${deniedRelations.map(() => '?').join(', ')}) AND ${active}`
|
|
800
|
+
: '');
|
|
790
801
|
const bindings = [
|
|
791
802
|
// grp ancla
|
|
792
803
|
partitionKey, object.type, object.id, ...relations, at,
|
|
@@ -796,22 +807,21 @@ export class DatabaseRelationsDriver {
|
|
|
796
807
|
partitionKey, object.type, object.id, ...relations, at,
|
|
797
808
|
// holders de los grupos
|
|
798
809
|
partitionKey, at,
|
|
810
|
+
// la puerta de denies (misma sentencia)
|
|
811
|
+
...(deniedRelations.length ? [partitionKey, object.type, object.id, ...deniedRelations, at] : []),
|
|
799
812
|
];
|
|
800
813
|
const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
|
|
801
|
-
const candidates = rows
|
|
814
|
+
const candidates = rows
|
|
815
|
+
.filter((row) => String(row.foto_kind) === 'm')
|
|
816
|
+
.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
|
|
817
|
+
const denied = rows.some((row) => String(row.foto_kind) === 'd');
|
|
802
818
|
// **Lote D · `membersOf` RESTA** (juez ronda 2: «listObjects y membersOf restan»; D8: siempre, con o sin opt-in).
|
|
803
819
|
// Si el objeto tiene alguna fila `denied_*` vigente, cada candidato se juzga con `check` —el MISMO corte de camino
|
|
804
820
|
// que el resto de lecturas, un `check` por candidato—; sin filas deny, cero consultas de más (la respuesta y el coste
|
|
805
|
-
// de 2.4.0-alpha.10). Coste declarado: O(miembros) consultas cuando el objeto tiene denies.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
.where('object_type', object.type)
|
|
810
|
-
.where('object_uuid', object.id)
|
|
811
|
-
.whereIn('relation', deniableAbove(this.#config, object.type, relation).map(deniedRelationOf)), this.#now())
|
|
812
|
-
.select('uuid')
|
|
813
|
-
.limit(1));
|
|
814
|
-
if (denied.length === 0)
|
|
821
|
+
// de 2.4.0-alpha.10). Coste declarado: O(miembros) consultas cuando el objeto tiene denies. La puerta sale de la
|
|
822
|
+
// MISMA sentencia que los candidatos (L12): sin deny en esa foto, todos los candidatos son miembros en ese estado.
|
|
823
|
+
// Con deny, cada candidato se juzga con su `check` (una foto por candidato, como cada posición de `authorizeMany`).
|
|
824
|
+
if (!denied)
|
|
815
825
|
return { subjects: candidates };
|
|
816
826
|
// **Cierre 1 de D1 · 🟡 4 del auditor**: con deny, N `check` en serie sin cota ni deadline total era una
|
|
817
827
|
// amplificación barata para quien puede escribir un deny (disponibilidad, no concesión). Cota DECLARADA
|
package/build/src/errors.d.ts
CHANGED
|
@@ -195,6 +195,24 @@ export declare class AuthorizationInternalError extends Exception {
|
|
|
195
195
|
export declare class UnknownScopeError extends Exception {
|
|
196
196
|
static status: number;
|
|
197
197
|
static code: string;
|
|
198
|
+
/**
|
|
199
|
+
* **Para el OPERADOR, no para el tenant** (rotación 2 · cierre 2 · RC2-A): en `updateScopedRole`/`deleteScopedRole` el
|
|
200
|
+
* scope que no resuelve es el OWNER del rol (dormido), que el llamante no pasó y puede ser de otro tenant: el `message`
|
|
201
|
+
* nombra el rol y el owner va aquí, NO enumerable (mismo patrón que `NotWithinError.details`). Ausente en el resto de
|
|
202
|
+
* rutas, donde el scope desconocido es el que pasó el llamante y el `message` lo nombra.
|
|
203
|
+
*/
|
|
204
|
+
readonly details: {
|
|
205
|
+
owner: {
|
|
206
|
+
type: string;
|
|
207
|
+
uuid: string | null;
|
|
208
|
+
};
|
|
209
|
+
} | undefined;
|
|
210
|
+
constructor(message?: string, details?: {
|
|
211
|
+
owner: {
|
|
212
|
+
type: string;
|
|
213
|
+
uuid: string | null;
|
|
214
|
+
};
|
|
215
|
+
});
|
|
198
216
|
}
|
|
199
217
|
/**
|
|
200
218
|
* Se preguntó por un scope que no es la raíz y el driver no tiene resolutor
|
|
@@ -458,6 +476,27 @@ export declare class ActorRequiredError extends Exception {
|
|
|
458
476
|
export declare class NotWithinError extends Exception {
|
|
459
477
|
static status: number;
|
|
460
478
|
static code: string;
|
|
479
|
+
/**
|
|
480
|
+
* **Para el OPERADOR, no para el tenant** (rotación 2 de M4 · R2-03, decisión del dueño 2026-10-22 (3)). Hasta
|
|
481
|
+
* 2.5.0-alpha.4 el `message` imprimía la cadena ENTERA del scope (`unit:<B1x> → unit:<B1> → organization:<org de B>
|
|
482
|
+
* → app`): el admin de A que conoce un uuid de B aprendía a qué tenant pertenece y su ruta, y un 422 es lo que un
|
|
483
|
+
* framework devuelve al cliente tal cual (la misma clase de fuga que 3E · Q2 cerró en `ROLE_NOT_VISIBLE`). Ahora el
|
|
484
|
+
* `message` nombra solo lo que el llamante ya tiene —el scope que pasó y su `within`— y la cadena va aquí, NO
|
|
485
|
+
* enumerable como `ScopeDepthLimitError.details` (alpha.8): `JSON.stringify(error)` —lo que un handler genérico
|
|
486
|
+
* manda— no la lleva, y el log del servidor la lee por `error.details.chain`. El CÓDIGO no cambia.
|
|
487
|
+
*/
|
|
488
|
+
readonly details: {
|
|
489
|
+
chain: ReadonlyArray<{
|
|
490
|
+
type: string;
|
|
491
|
+
uuid: string | null;
|
|
492
|
+
}>;
|
|
493
|
+
} | undefined;
|
|
494
|
+
constructor(message: string, details?: {
|
|
495
|
+
chain: ReadonlyArray<{
|
|
496
|
+
type: string;
|
|
497
|
+
uuid: string | null;
|
|
498
|
+
}>;
|
|
499
|
+
});
|
|
461
500
|
}
|
|
462
501
|
/**
|
|
463
502
|
* `config.requireWithin` (`true` o `'non-root'`) y una escritura llegó sin
|
package/build/src/errors.js
CHANGED
|
@@ -267,6 +267,10 @@ export class AuthorizationInternalError extends Exception {
|
|
|
267
267
|
export class UnknownScopeError extends Exception {
|
|
268
268
|
static status = 422;
|
|
269
269
|
static code = 'E_AUTHZ_UNKNOWN_SCOPE';
|
|
270
|
+
constructor(message, details) {
|
|
271
|
+
super(message);
|
|
272
|
+
Object.defineProperty(this, 'details', { value: details, enumerable: false, configurable: true, writable: false });
|
|
273
|
+
}
|
|
270
274
|
}
|
|
271
275
|
/**
|
|
272
276
|
* Se preguntó por un scope que no es la raíz y el driver no tiene resolutor
|
|
@@ -565,6 +569,10 @@ export class ActorRequiredError extends Exception {
|
|
|
565
569
|
export class NotWithinError extends Exception {
|
|
566
570
|
static status = 422;
|
|
567
571
|
static code = 'E_AUTHZ_NOT_WITHIN';
|
|
572
|
+
constructor(message, details) {
|
|
573
|
+
super(message);
|
|
574
|
+
Object.defineProperty(this, 'details', { value: details, enumerable: false, configurable: true, writable: false });
|
|
575
|
+
}
|
|
568
576
|
}
|
|
569
577
|
/**
|
|
570
578
|
* `config.requireWithin` (`true` o `'non-root'`) y una escritura llegó sin
|
package/build/src/identity.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NormalizedRoleQuery, RoleQuery, ScopeRef, SubjectRef } from './types.js';
|
|
1
|
+
import type { NormalizedRoleQuery, RoleQuery, ScopeRef, SnapshotReads, SubjectRef } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Validación de identidad del motor: holder, scope, rol y permiso.
|
|
4
4
|
*
|
|
@@ -338,3 +338,9 @@ export interface IdentityParts {
|
|
|
338
338
|
* cuesta una consulta ni deja rastro.
|
|
339
339
|
*/
|
|
340
340
|
export declare function assertIdentity(parts: IdentityParts): void;
|
|
341
|
+
/**
|
|
342
|
+
* La forma de una pregunta a `readSnapshot` (rotación 2 · cierre 3): al menos una de las tres claves y ninguna más;
|
|
343
|
+
* `rolesInChain` un array de scopes válidos, `listScopes` un slug de permiso, `listDenies` exactamente `true`. Lo
|
|
344
|
+
* demás es 422 `E_AUTHZ_INVALID_IDENTITY` antes de tocar el backend. Devuelve la pregunta con las claves presentes.
|
|
345
|
+
*/
|
|
346
|
+
export declare function assertSnapshotReads(reads: unknown): SnapshotReads;
|
package/build/src/identity.js
CHANGED
|
@@ -610,3 +610,32 @@ export function assertIdentity(parts) {
|
|
|
610
610
|
if ('expiresAt' in parts)
|
|
611
611
|
assertExpiresAt(parts.expiresAt);
|
|
612
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* La forma de una pregunta a `readSnapshot` (rotación 2 · cierre 3): al menos una de las tres claves y ninguna más;
|
|
615
|
+
* `rolesInChain` un array de scopes válidos, `listScopes` un slug de permiso, `listDenies` exactamente `true`. Lo
|
|
616
|
+
* demás es 422 `E_AUTHZ_INVALID_IDENTITY` antes de tocar el backend. Devuelve la pregunta con las claves presentes.
|
|
617
|
+
*/
|
|
618
|
+
export function assertSnapshotReads(reads) {
|
|
619
|
+
if (reads === null || typeof reads !== 'object' || Array.isArray(reads)) {
|
|
620
|
+
throw new InvalidIdentityError(`readSnapshot: se esperaba un objeto { rolesInChain?, listScopes?, listDenies? } y llegó ${Array.isArray(reads) ? 'un array' : typeof reads}`);
|
|
621
|
+
}
|
|
622
|
+
const known = new Set(['rolesInChain', 'listScopes', 'listDenies']);
|
|
623
|
+
const present = Object.keys(reads).filter((key) => reads[key] !== undefined);
|
|
624
|
+
const unknown = present.filter((key) => !known.has(key));
|
|
625
|
+
if (unknown.length)
|
|
626
|
+
throw new InvalidIdentityError(`readSnapshot: clave desconocida ${unknown.map((k) => `'${k}'`).join(', ')}`);
|
|
627
|
+
if (present.length === 0)
|
|
628
|
+
throw new InvalidIdentityError('readSnapshot: no se pidió ninguna lectura');
|
|
629
|
+
const { rolesInChain, listScopes, listDenies } = reads;
|
|
630
|
+
if (rolesInChain !== undefined) {
|
|
631
|
+
if (!Array.isArray(rolesInChain))
|
|
632
|
+
throw new InvalidIdentityError('readSnapshot: rolesInChain tiene que ser un array de scopes');
|
|
633
|
+
for (const scope of rolesInChain)
|
|
634
|
+
assertScope(scope);
|
|
635
|
+
}
|
|
636
|
+
if (listScopes !== undefined)
|
|
637
|
+
assertIdentity({ permission: listScopes });
|
|
638
|
+
if (listDenies !== undefined && listDenies !== true)
|
|
639
|
+
throw new InvalidIdentityError('readSnapshot: listDenies solo admite true');
|
|
640
|
+
return { rolesInChain, listScopes, listDenies };
|
|
641
|
+
}
|