@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
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import db from '@adonisjs/lucid/services/db';
|
|
2
|
+
import { guardSql, isAuthzError, isSqlDriverError, isTimeoutLike } from './drivers/backend_guard.js';
|
|
3
|
+
import { AmbiguousRoleError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, } from './errors.js';
|
|
4
|
+
import { isValidScopeType, scopeFromKey } from './identity.js';
|
|
5
|
+
import { systemClock } from './clock.js';
|
|
6
|
+
import { isSqliteDialect } from './drivers/sql_expiry.js';
|
|
7
|
+
/**
|
|
8
|
+
* Memo del CATÁLOGO (roles, permisos y vínculos rol→permiso), y de nada más.
|
|
9
|
+
*
|
|
10
|
+
* El camino caliente de `authorize` consultaba `authz_*` en cada pregunta
|
|
11
|
+
* (`findPermission` en ambos drivers; `rolesGranting` además en openfga)
|
|
12
|
+
* para leer algo que solo cambia cuando el consumidor sincroniza su config.
|
|
13
|
+
* Este memo carga las tres tablas una vez, perezosamente, y las sirve desde
|
|
14
|
+
* memoria mientras la BASE diga que siguen vigentes. Lo que NUNCA cachea:
|
|
15
|
+
* hechos (asignaciones, denies) ni decisiones — un `authorize` siempre
|
|
16
|
+
* pregunta al backend de hechos; lo que se ahorra es el "¿qué uuid tiene
|
|
17
|
+
* `docs:read`?".
|
|
18
|
+
*
|
|
19
|
+
* Invariante (2D · F1): **el catálogo que decide es el de la BD.** La tabla
|
|
20
|
+
* `authz_catalog_version` (una fila, `id = 1`) lleva un entero que
|
|
21
|
+
* `syncAuthzCatalog` incrementa DENTRO de su transacción. Cada foto recuerda
|
|
22
|
+
* con qué versión se cargó y, antes de servirse, la contrasta con la fila
|
|
23
|
+
* (un SELECT por clave primaria, con deadline y clasificado 503): si difiere,
|
|
24
|
+
* recarga. Así un sync en OTRO proceso (otro worker, `node ace
|
|
25
|
+
* authz:catalog:sync` en un despliegue) se ve en la siguiente pregunta de
|
|
26
|
+
* todos los procesos, sin pub/sub y sin reiniciar. El memo nunca sirve una
|
|
27
|
+
* decisión con una versión distinta de la de la base.
|
|
28
|
+
*
|
|
29
|
+
* Contrato de invalidación (README, "Performance"):
|
|
30
|
+
* 1. `syncAuthzCatalog` (y por tanto `authz:catalog:sync`) sube la fila en
|
|
31
|
+
* su transacción (lo ven todos los procesos) y además el contador de
|
|
32
|
+
* este módulo (lo ve este proceso al instante, también con `everyMs`).
|
|
33
|
+
* 2. `invalidateAuthzCatalog()` invalida los memos de ESTE proceso; la fila
|
|
34
|
+
* la sube `bumpAuthzCatalogVersion(trx)` para TODOS, y SOLO como última
|
|
35
|
+
* sentencia de la transacción que escribe `authz_*` (2E · H2): un bump
|
|
36
|
+
* que se confirma antes que su escritura hace que otro proceso recargue
|
|
37
|
+
* los datos viejos etiquetados con la versión nueva y no vuelva a
|
|
38
|
+
* revalidar jamás. Quien escribe `authz_*` por fuera del sync (un seeder,
|
|
39
|
+
* una migración de datos) lo hace con `withAuthzCatalogWrite(async (trx)
|
|
40
|
+
* => …)`, que abre la transacción y sube la versión al final, dentro; sin
|
|
41
|
+
* eso los demás procesos no se enteran (caso negativo fijado por test).
|
|
42
|
+
* 3. `revalidate: 'always'` (default) contrasta la fila en cada `view()`.
|
|
43
|
+
* `{ everyMs }` (opt-in) la contrasta como mucho una vez por ventana: es
|
|
44
|
+
* una ventana ACOTADA de catálogo viejo —de revocación fail-open— que el
|
|
45
|
+
* consumidor acepta a sabiendas a cambio de ahorrarse ese SELECT.
|
|
46
|
+
*
|
|
47
|
+
* Es composición: cada driver del paquete construye el suyo (o recibe uno
|
|
48
|
+
* compartido en `catalog`); un driver de terceros no necesita saber que existe.
|
|
49
|
+
*/
|
|
50
|
+
/** Tabla de la versión compartida del catálogo (una fila, `id = 1`). */
|
|
51
|
+
export const CATALOG_VERSION_TABLE = 'authz_catalog_version';
|
|
52
|
+
const CATALOG_VERSION_ROW_ID = 1;
|
|
53
|
+
/**
|
|
54
|
+
* Clave de owner de los roles GLOBALES (`authz_roles.owner_scope_key`, 3B ·
|
|
55
|
+
* B1): los del catálogo del config (`syncAuthzCatalog`). Reservada: ningún
|
|
56
|
+
* `scopeKey()` la produce (la raíz da `app`; el resto lleva `|`).
|
|
57
|
+
*/
|
|
58
|
+
export const GLOBAL_OWNER_KEY = 'global';
|
|
59
|
+
/** ¿El rol cuenta en un scope cuya cadena tiene esas claves (owner global o en la cadena)? */
|
|
60
|
+
export function isRoleVisibleWith(role, chainKeys) {
|
|
61
|
+
if (role.owner === GLOBAL_OWNER_KEY)
|
|
62
|
+
return true;
|
|
63
|
+
return Array.isArray(chainKeys) ? chainKeys.includes(role.owner) : chainKeys.has(role.owner);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Versión del catálogo en ESTE proceso. La sube `syncAuthzCatalog` al
|
|
67
|
+
* terminar e `invalidateAuthzCatalog()`; cada memo recuerda con qué versión
|
|
68
|
+
* cargó y se recarga si difiere. Es la señal intra-proceso (inmediata, sin
|
|
69
|
+
* SQL); la señal entre procesos es la fila `authz_catalog_version`.
|
|
70
|
+
*/
|
|
71
|
+
let catalogVersion = 0;
|
|
72
|
+
/**
|
|
73
|
+
* Invalida todos los memos del catálogo de este proceso. En otro proceso no
|
|
74
|
+
* hace nada: para eso está la fila compartida (`bumpAuthzCatalogVersion`).
|
|
75
|
+
*/
|
|
76
|
+
export function invalidateAuthzCatalog() {
|
|
77
|
+
catalogVersion += 1;
|
|
78
|
+
}
|
|
79
|
+
/** Deadline de cada consulta de carga (el mismo default que el catálogo). */
|
|
80
|
+
export const DEFAULT_CATALOG_CACHE_TIMEOUT_MS = 5_000;
|
|
81
|
+
function describeClient(client) {
|
|
82
|
+
if (client === null)
|
|
83
|
+
return 'null';
|
|
84
|
+
if (client === undefined)
|
|
85
|
+
return 'nada';
|
|
86
|
+
if (typeof client !== 'object' && typeof client !== 'function')
|
|
87
|
+
return `un ${typeof client}`;
|
|
88
|
+
const c = client;
|
|
89
|
+
if (c.isTransaction === false)
|
|
90
|
+
return `un cliente que NO es una transacción (${c.constructor?.name ?? 'objeto'})`;
|
|
91
|
+
return `un ${c.constructor?.name ?? 'objeto'} sin isTransaction`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* `bumpAuthzCatalogVersion` exige la transacción que escribe `authz_*` (2E ·
|
|
95
|
+
* H2, auditor 2): con el `db` global la subida se confirmaba ANTES que la
|
|
96
|
+
* escritura (fuera de la transacción del consumidor) y el memo de otro
|
|
97
|
+
* proceso recargaba los datos viejos con la etiqueta nueva — un fail-open
|
|
98
|
+
* permanente. 500: es un error de programación, no una pregunta.
|
|
99
|
+
*/
|
|
100
|
+
function assertTransactionClient(client, operation) {
|
|
101
|
+
const c = client;
|
|
102
|
+
if (!c || typeof c.from !== 'function' || typeof c.table !== 'function' || c.isTransaction !== true) {
|
|
103
|
+
throw new AuthorizationConfigError(`${operation} exige el cliente de la TRANSACCIÓN que escribe authz_* (el trx de db.transaction) y llegó ` +
|
|
104
|
+
`${describeClient(client)}. Un bump fuera de esa transacción se confirma antes que la escritura y deja a los ` +
|
|
105
|
+
`demás procesos con el catálogo viejo etiquetado como nuevo, para siempre. Escribe authz_* con ` +
|
|
106
|
+
`withAuthzCatalogWrite(async (trx) => { … }): abre la transacción, ejecuta tu escritura y sube la versión ` +
|
|
107
|
+
`como última sentencia, dentro.`);
|
|
108
|
+
}
|
|
109
|
+
return c;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `catalog` (memo compartido) y `catalogRevalidate` juntos se contradicen
|
|
113
|
+
* (2E · I3, auditor 11): la política de revalidación es la del memo y se fija
|
|
114
|
+
* al construir el `CatalogCache`; el `catalogRevalidate` del driver se
|
|
115
|
+
* ignoraba en silencio. 500 al construir: config rota, no una pregunta. Lo
|
|
116
|
+
* llaman los dos drivers del paquete desde su constructor.
|
|
117
|
+
*/
|
|
118
|
+
export function assertCatalogOptions(driver, options) {
|
|
119
|
+
if (options.catalog !== undefined && options.catalogRevalidate !== undefined) {
|
|
120
|
+
throw new AuthorizationConfigError(`${driver}: 'catalog' (memo compartido) y 'catalogRevalidate' no pueden ir juntos: la política de ` +
|
|
121
|
+
`revalidación es la del memo que se comparte (new CatalogCache({ revalidate })) y la del driver se ` +
|
|
122
|
+
`ignoraría. Quita 'catalogRevalidate' o construye el driver sin 'catalog'.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* La fila de la versión no está o no se puede leer como número (2E · I1,
|
|
127
|
+
* auditor 7): es una base sin la migración 2.0 (que la siembra) o con la fila
|
|
128
|
+
* borrada. Fail-closed: sin versión legible no se sirve ningún catálogo — ni
|
|
129
|
+
* el memo viejo ni una carga en frío etiquetada como «versión 0».
|
|
130
|
+
*/
|
|
131
|
+
function unreadableVersionRow(driver, why) {
|
|
132
|
+
const error = new AuthorizationBackendError(driver, 'catalog.version', new Error(why));
|
|
133
|
+
error.message =
|
|
134
|
+
`El backend de autorización '${driver}' no tiene una versión legible del catálogo (${CATALOG_VERSION_TABLE}, ` +
|
|
135
|
+
`id = ${CATALOG_VERSION_ROW_ID}): ${why}. Probablemente la migración 2.0 no está aplicada (siembra la fila); ` +
|
|
136
|
+
`sin ella no se sirve ningún catálogo.`;
|
|
137
|
+
return error;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Versión compartida del catálogo: la fila `authz_catalog_version`. Sin fila
|
|
141
|
+
* legible (semilla ausente, fila borrada, valor no numérico) es 503
|
|
142
|
+
* `E_AUTHZ_BACKEND_UNAVAILABLE` con «migración 2.0 no aplicada» (I1); sin
|
|
143
|
+
* TABLA se clasifica como el resto de fallos SQL (503).
|
|
144
|
+
*/
|
|
145
|
+
export async function readAuthzCatalogVersion(options = {}) {
|
|
146
|
+
const client = options.client ?? db;
|
|
147
|
+
const driver = options.driver ?? 'catalog';
|
|
148
|
+
const rows = await guardSql(driver, 'catalog.version', options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS, () => client.from(CATALOG_VERSION_TABLE).where('id', CATALOG_VERSION_ROW_ID).select('version'));
|
|
149
|
+
if (rows.length === 0)
|
|
150
|
+
throw unreadableVersionRow(driver, 'la fila no existe');
|
|
151
|
+
const raw = rows[0].version;
|
|
152
|
+
const version = typeof raw === 'number' ? raw : typeof raw === 'string' || typeof raw === 'bigint' ? Number(raw) : Number.NaN;
|
|
153
|
+
if (!Number.isFinite(version))
|
|
154
|
+
throw unreadableVersionRow(driver, `la columna version vale ${String(raw)}`);
|
|
155
|
+
return version;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Sube la versión compartida del catálogo: TODOS los memos de TODOS los
|
|
159
|
+
* procesos recargan en su siguiente pregunta (o al cerrar su ventana
|
|
160
|
+
* `everyMs`). Exige el cliente de la transacción que escribe `authz_*` (500
|
|
161
|
+
* `E_AUTHZ_CONFIG` sin él, 2E · H2) y tiene que ser su ÚLTIMA sentencia: lo
|
|
162
|
+
* garantiza `withAuthzCatalogWrite`, que es por donde escriben
|
|
163
|
+
* `syncAuthzCatalog` y cualquier seeder o migración de datos. Sin fila
|
|
164
|
+
* (semilla ausente) la crea; la carrera de dos procesos que la crean a la vez
|
|
165
|
+
* la resuelve la clave primaria: el perdedor vuelve a hacer UPDATE. Es SOLO el
|
|
166
|
+
* canal entre procesos: no toca el contador de este (con `everyMs`, hasta este
|
|
167
|
+
* proceso tarda una ventana en verlo; con `'always'`, la siguiente pregunta lo
|
|
168
|
+
* ve).
|
|
169
|
+
*/
|
|
170
|
+
export async function bumpAuthzCatalogVersion(trx, options = {}) {
|
|
171
|
+
const client = assertTransactionClient(trx, 'bumpAuthzCatalogVersion');
|
|
172
|
+
const driver = options.driver ?? 'catalog';
|
|
173
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
|
|
174
|
+
const update = () => guardSql(driver, 'catalog.version.bump', timeoutMs, () => client
|
|
175
|
+
.from(CATALOG_VERSION_TABLE)
|
|
176
|
+
.where('id', CATALOG_VERSION_ROW_ID)
|
|
177
|
+
.increment('version', 1));
|
|
178
|
+
const updated = Number(await update());
|
|
179
|
+
if (updated > 0)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
await guardSql(driver, 'catalog.version.seed', timeoutMs, () => client.table(CATALOG_VERSION_TABLE).insert({ id: CATALOG_VERSION_ROW_ID, version: 1, updated_at: systemClock() }));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// Otro proceso sembró la fila entre el UPDATE y el INSERT: se sube la suya.
|
|
186
|
+
if (Number(await update()) === 0)
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Los roles LOCALES de esos owners leídos de la BASE, en fresco, con sus
|
|
192
|
+
* permisos (3E · P2, auditor A2 bis).
|
|
193
|
+
*
|
|
194
|
+
* `scopes.detached` decidía qué purgar con la foto del MEMO: con
|
|
195
|
+
* `catalogRevalidate: { everyMs }` —config legal y documentada— un rol que
|
|
196
|
+
* otro proceso acababa de confirmar no estaba en la foto y SOBREVIVÍA a la
|
|
197
|
+
* desaparición de su owner, bloqueando ese `(slug, nivel)` para el catálogo
|
|
198
|
+
* global para siempre. M2 ya había aprendido a releer la base dentro de la
|
|
199
|
+
* transacción; M4 no.
|
|
200
|
+
*
|
|
201
|
+
* Queda una ventana (un `defineScopedRole` confirmado entre este SELECT y la
|
|
202
|
+
* purga) que no cierra ningún cerrojo razonable: `purgeRole` abre su propia
|
|
203
|
+
* transacción con el cerrojo del catálogo y leer aquí dentro de otra sería
|
|
204
|
+
* un abrazo mortal con un pool de 1. Es una carrera que el tenant pierde de
|
|
205
|
+
* todas formas —define un rol en un scope que se está borrando— y la
|
|
206
|
+
* siguiente notificación (o `authz:reconcile`, 3b) lo recoge.
|
|
207
|
+
*
|
|
208
|
+
* El orden es ESTABLE por `uuid` (3F · U5, tester 3E · §4.3): sin `ORDER BY`
|
|
209
|
+
* lo ponía el motor, así que la secuencia de `role_purged` que ve el hook de
|
|
210
|
+
* auditoría —y el rol por el que empezaría una purga interrumpida a medias—
|
|
211
|
+
* cambiaba entre PostgreSQL, MySQL y SQLite. La policy de rango se comprueba
|
|
212
|
+
* sobre TODOS antes de tocar ninguno (3E · P3), así que el orden no decide
|
|
213
|
+
* QUÉ se purga; decide lo que se REPRODUCE. Con uuid v7 es además el orden
|
|
214
|
+
* de creación.
|
|
215
|
+
*/
|
|
216
|
+
export async function readRolesOwnedBy(ownerKeys, options = {}) {
|
|
217
|
+
const owners = [...new Set(ownerKeys)].filter((key) => key !== GLOBAL_OWNER_KEY);
|
|
218
|
+
if (owners.length === 0)
|
|
219
|
+
return [];
|
|
220
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
|
|
221
|
+
const driver = options.driver ?? 'catalog';
|
|
222
|
+
const rows = [];
|
|
223
|
+
// Por lotes: `descendantsOf` puede traer miles de owners y un `IN` de
|
|
224
|
+
// 10 000 elementos es una consulta que algunos motores rechazan.
|
|
225
|
+
for (let i = 0; i < owners.length; i += 500) {
|
|
226
|
+
const chunk = owners.slice(i, i + 500);
|
|
227
|
+
rows.push(...(await guardSql(driver, 'catalog.rolesOwnedBy', timeoutMs, () => db
|
|
228
|
+
.from('authz_roles')
|
|
229
|
+
.whereIn('owner_scope_key', chunk)
|
|
230
|
+
.orderBy('uuid', 'asc')
|
|
231
|
+
.select('uuid', 'slug', 'scope_type', 'rank', 'owner_scope_key'))));
|
|
232
|
+
}
|
|
233
|
+
if (rows.length === 0)
|
|
234
|
+
return [];
|
|
235
|
+
// Los lotes se concatenan en el orden de `owners`, así que el orden estable
|
|
236
|
+
// del conjunto entero se fija aquí.
|
|
237
|
+
rows.sort((a, b) => String(a.uuid).localeCompare(String(b.uuid)));
|
|
238
|
+
const links = await guardSql(driver, 'catalog.rolePermissionsOwnedBy', timeoutMs, () => db
|
|
239
|
+
.from('authz_role_permissions')
|
|
240
|
+
.join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
|
|
241
|
+
.whereIn('authz_role_permissions.role_uuid', rows.map((row) => String(row.uuid)))
|
|
242
|
+
.select('authz_role_permissions.role_uuid as role_uuid', 'authz_permissions.slug as permission_slug'));
|
|
243
|
+
const permissionsOf = new Map();
|
|
244
|
+
for (const link of links) {
|
|
245
|
+
const list = permissionsOf.get(String(link.role_uuid)) ?? [];
|
|
246
|
+
list.push(String(link.permission_slug));
|
|
247
|
+
permissionsOf.set(String(link.role_uuid), list);
|
|
248
|
+
}
|
|
249
|
+
return rows.map((row) => ({
|
|
250
|
+
role: Object.freeze({
|
|
251
|
+
uuid: String(row.uuid),
|
|
252
|
+
slug: String(row.slug),
|
|
253
|
+
scopeType: String(row.scope_type),
|
|
254
|
+
owner: String(row.owner_scope_key),
|
|
255
|
+
rank: Number(row.rank),
|
|
256
|
+
}),
|
|
257
|
+
permissions: (permissionsOf.get(String(row.uuid)) ?? []).sort(),
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Serializa las escrituras del CATÁLOGO bloqueando la fila de
|
|
262
|
+
* `authz_catalog_version` (3D · M2, auditor V2 🔴).
|
|
263
|
+
*
|
|
264
|
+
* La unicidad de la que depende todo el modelo de roles locales —«dentro de
|
|
265
|
+
* una cadena un `(slug, nivel)` identifica un solo rol»— era un
|
|
266
|
+
* *read-then-write*: cada escritor comprobaba la colisión contra SU foto del
|
|
267
|
+
* memo y el unique de la base es `(slug, scope_type, owner_scope_key)`, así
|
|
268
|
+
* que dos `define` con owners distintos (o un `define` contra un `sync`)
|
|
269
|
+
* insertaban los dos y dejaban dos homónimos PERMANENTES. Un `SELECT … FOR
|
|
270
|
+
* UPDATE` sobre `authz_roles WHERE slug=? AND scope_type=?` no lo cierra: no
|
|
271
|
+
* hay filas que bloquear (en PostgreSQL no hay gap locks), que es justo el
|
|
272
|
+
* caso. La fila de la versión sí existe siempre y TODA escritura de `authz_*`
|
|
273
|
+
* pasa por aquí, así que bloquearla es la barrera real: los escritores del
|
|
274
|
+
* catálogo van en serie y el re-chequeo de colisión dentro de la transacción
|
|
275
|
+
* (leyendo la BASE, no el memo) ve lo que el anterior confirmó.
|
|
276
|
+
*
|
|
277
|
+
* Coste: las escrituras del catálogo son raras (un sync por despliegue, la
|
|
278
|
+
* API de delegación). Los HECHOS (`grant`/`deny`/…) no pasan por aquí.
|
|
279
|
+
*
|
|
280
|
+
* SQLite no tiene `FOR UPDATE` y no lo necesita: sus escrituras ya se
|
|
281
|
+
* serializan a nivel de base (una transacción de escritura a la vez).
|
|
282
|
+
*/
|
|
283
|
+
async function lockCatalogForWrite(trx, options) {
|
|
284
|
+
// 3E · Q5: Lucid llama `better-sqlite3` a su dialecto de SQLite.
|
|
285
|
+
if (isSqliteDialect(trx))
|
|
286
|
+
return;
|
|
287
|
+
await guardSql(options.driver, 'catalog.lock', options.timeoutMs, () => trx.from(CATALOG_VERSION_TABLE).where('id', CATALOG_VERSION_ROW_ID).forUpdate().select('version'));
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* LA forma de escribir `authz_*` por fuera de `syncAuthzCatalog` (2E · H2):
|
|
291
|
+
* abre una transacción, ejecuta `fn(trx)` —tu escritura, con ESE cliente— y
|
|
292
|
+
* sube `authz_catalog_version` como última sentencia, dentro. O se confirma
|
|
293
|
+
* todo (datos nuevos + versión nueva) o nada: un memo de otro proceso nunca
|
|
294
|
+
* puede leer la versión nueva con los datos viejos. Devuelve lo que devuelva
|
|
295
|
+
* `fn`. Si `fn` lanza, la transacción se revierte, la versión no sube y su
|
|
296
|
+
* error sale tal cual (es tuyo) — salvo que sea un error del cliente SQL
|
|
297
|
+
* (2.5-B · K12), que se clasifica como 503 igual que un fallo al abrir o
|
|
298
|
+
* confirmar la transacción. NO te tragues errores de SQL dentro de `fn`: en
|
|
299
|
+
* PostgreSQL la transacción queda abortada y todo lo que sigue falla; en
|
|
300
|
+
* MySQL y SQLite el motor no la aborta y lo que sigue SE CONFIRMA.
|
|
301
|
+
*
|
|
302
|
+
* Es solo el canal entre procesos (la fila): en este proceso la siguiente
|
|
303
|
+
* pregunta lo ve con `'always'` y, con `{ everyMs }`, al cerrar la ventana —
|
|
304
|
+
* `syncAuthzCatalog` además invalida en memoria; hazlo tú con
|
|
305
|
+
* `invalidateAuthzCatalog()` si usas `everyMs` y lo necesitas al instante.
|
|
306
|
+
*
|
|
307
|
+
* await withAuthzCatalogWrite(async (trx) => {
|
|
308
|
+
* await trx.from('authz_role_permissions').where('role_uuid', role).delete()
|
|
309
|
+
* })
|
|
310
|
+
*/
|
|
311
|
+
export async function withAuthzCatalogWrite(fn, options = {}) {
|
|
312
|
+
if (typeof fn !== 'function') {
|
|
313
|
+
throw new AuthorizationConfigError(`withAuthzCatalogWrite espera la función que escribe authz_* (async (trx) => …) y llegó ${typeof fn}`);
|
|
314
|
+
}
|
|
315
|
+
const driver = options.driver ?? 'catalog';
|
|
316
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
|
|
317
|
+
const connection = options.connection ? db.connection(options.connection) : db;
|
|
318
|
+
// Lo que lance `fn` es del consumidor y sale intacto; lo que falle al abrir
|
|
319
|
+
// o confirmar la transacción es la base y se clasifica (503).
|
|
320
|
+
let consumerError = null;
|
|
321
|
+
try {
|
|
322
|
+
return await connection.transaction(async (trx) => {
|
|
323
|
+
// Primero el cerrojo del catálogo (M2), después la escritura y, como
|
|
324
|
+
// última sentencia, el bump (H2).
|
|
325
|
+
await lockCatalogForWrite(trx, { driver, timeoutMs });
|
|
326
|
+
let result;
|
|
327
|
+
try {
|
|
328
|
+
result = await fn(trx);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
consumerError = { error };
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
await bumpAuthzCatalogVersion(trx, { driver, timeoutMs });
|
|
335
|
+
return result;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
// Lo que `fn` lanzó y es SUYO (su `Error`, un 422 del paquete) sale
|
|
340
|
+
// intacto. Lo que `fn` dejó escapar del CLIENTE SQL (2.5-B · K12: en
|
|
341
|
+
// PostgreSQL, tras tragarse un fallo, la transacción está abortada y el
|
|
342
|
+
// siguiente UPDATE lanza `25P02` con el SQL dentro) se clasifica igual
|
|
343
|
+
// que un fallo al abrir o confirmar: 503, causa conservada, sin SQL en el
|
|
344
|
+
// mensaje. `fn` NO debe tragarse errores de SQL: en MySQL y SQLite el
|
|
345
|
+
// motor no aborta la transacción y lo que sigue SE CONFIRMA.
|
|
346
|
+
const fromConsumer = consumerError !== null && consumerError.error === error;
|
|
347
|
+
if (fromConsumer && !isSqlDriverError(error))
|
|
348
|
+
throw error;
|
|
349
|
+
if (isAuthzError(error))
|
|
350
|
+
throw error;
|
|
351
|
+
if (isTimeoutLike(error))
|
|
352
|
+
throw new AuthorizationBackendTimeoutError(driver, 'catalog.write', timeoutMs, error);
|
|
353
|
+
throw new AuthorizationBackendError(driver, 'catalog.write', error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
export class CatalogCache {
|
|
357
|
+
#view = null;
|
|
358
|
+
#version = -1;
|
|
359
|
+
/** Generación de ESTA instancia: `invalidate()` la sube; una carga captura la suya antes de leer (F4). */
|
|
360
|
+
#generation = 0;
|
|
361
|
+
#loadedGeneration = -1;
|
|
362
|
+
#loading = null;
|
|
363
|
+
#checking = null;
|
|
364
|
+
/** Último instante (reloj monótono) en que la foto se contrastó con la fila (base de `everyMs`). */
|
|
365
|
+
#checkedAt = 0;
|
|
366
|
+
#everyMs;
|
|
367
|
+
#timeoutMs;
|
|
368
|
+
#driver;
|
|
369
|
+
/** Reloj monótono: nunca `Date.now()` para medir una ventana (H3). */
|
|
370
|
+
#now;
|
|
371
|
+
constructor(options = {}) {
|
|
372
|
+
const revalidate = options.revalidate ?? 'always';
|
|
373
|
+
if (revalidate !== 'always') {
|
|
374
|
+
const everyMs = revalidate?.everyMs;
|
|
375
|
+
if (typeof everyMs !== 'number' || !(Number.isFinite(everyMs) && everyMs > 0)) {
|
|
376
|
+
throw new TypeError(`CatalogCache: revalidate debe ser 'always' o { everyMs: número > 0 } (llegó ${JSON.stringify(revalidate)})`);
|
|
377
|
+
}
|
|
378
|
+
this.#everyMs = everyMs;
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
this.#everyMs = null;
|
|
382
|
+
}
|
|
383
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
|
|
384
|
+
this.#driver = options.driver ?? 'catalog';
|
|
385
|
+
if (options.now !== undefined && typeof options.now !== 'function') {
|
|
386
|
+
throw new TypeError(`CatalogCache: now debe ser una función (llegó ${typeof options.now})`);
|
|
387
|
+
}
|
|
388
|
+
this.#now = options.now ?? (() => performance.now());
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* La foto vigente. Sin foto, o invalidada en este proceso ⇒ carga (las
|
|
392
|
+
* llamadas concurrentes comparten la misma promesa: un arranque con cien
|
|
393
|
+
* requests no dispara cien cargas). Con foto ⇒ se contrasta su versión
|
|
394
|
+
* con la fila compartida (según `revalidate`; las comprobaciones
|
|
395
|
+
* concurrentes también comparten promesa) y, si la base va por delante,
|
|
396
|
+
* recarga. Una carga o comprobación que falla (503, clasificado por
|
|
397
|
+
* `guardSql`) no deja nada cacheado ni servido: la siguiente pregunta
|
|
398
|
+
* vuelve a intentarlo.
|
|
399
|
+
*/
|
|
400
|
+
async view() {
|
|
401
|
+
const current = this.#view;
|
|
402
|
+
if (current && this.#isFresh()) {
|
|
403
|
+
if (!this.#needsCheck())
|
|
404
|
+
return current;
|
|
405
|
+
if (!this.#checking) {
|
|
406
|
+
this.#checking = this.#revalidate(current).finally(() => {
|
|
407
|
+
this.#checking = null;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
return this.#checking;
|
|
411
|
+
}
|
|
412
|
+
return this.#reload();
|
|
413
|
+
}
|
|
414
|
+
/** Una carga compartida; `knownVersion` es la fila recién leída por una revalidación (se ahorra releerla). */
|
|
415
|
+
#reload(knownVersion) {
|
|
416
|
+
if (!this.#loading) {
|
|
417
|
+
this.#loading = this.#load(knownVersion).finally(() => {
|
|
418
|
+
this.#loading = null;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return this.#loading;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Olvida la foto de ESTE memo. La siguiente pregunta recarga. Es un bump
|
|
425
|
+
* de generación, no un `#view = null`: una carga en vuelo capturó la
|
|
426
|
+
* generación anterior y su foto aterriza ya vieja (F4, CR2).
|
|
427
|
+
*/
|
|
428
|
+
invalidate() {
|
|
429
|
+
this.#generation += 1;
|
|
430
|
+
}
|
|
431
|
+
/** ¿Hay una foto cargada y vigente para este proceso? (Observabilidad para tests y diagnóstico; no consulta la base.) */
|
|
432
|
+
get loaded() {
|
|
433
|
+
return this.#view !== null && this.#isFresh();
|
|
434
|
+
}
|
|
435
|
+
#isFresh() {
|
|
436
|
+
return this.#version === catalogVersion && this.#loadedGeneration === this.#generation;
|
|
437
|
+
}
|
|
438
|
+
#needsCheck() {
|
|
439
|
+
if (this.#everyMs === null)
|
|
440
|
+
return true;
|
|
441
|
+
return this.#now() - this.#checkedAt >= this.#everyMs;
|
|
442
|
+
}
|
|
443
|
+
#sql(operation, fn) {
|
|
444
|
+
return guardSql(this.#driver, operation, this.#timeoutMs, fn);
|
|
445
|
+
}
|
|
446
|
+
#readVersion() {
|
|
447
|
+
return readAuthzCatalogVersion({ driver: this.#driver, timeoutMs: this.#timeoutMs });
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Contrasta la foto con la fila: misma versión ⇒ se sirve; distinta ⇒
|
|
451
|
+
* recarga. Si la foto cambió mientras se leía la fila (otra carga terminó
|
|
452
|
+
* antes) se sirve lo que haya ahora, que ya es más nuevo.
|
|
453
|
+
*/
|
|
454
|
+
async #revalidate(current) {
|
|
455
|
+
const dbVersion = await this.#readVersion();
|
|
456
|
+
if (this.#view !== current)
|
|
457
|
+
return this.view();
|
|
458
|
+
if (dbVersion === current.version && this.#isFresh()) {
|
|
459
|
+
this.#checkedAt = this.#now();
|
|
460
|
+
return current;
|
|
461
|
+
}
|
|
462
|
+
return this.#reload(dbVersion);
|
|
463
|
+
}
|
|
464
|
+
async #load(knownVersion) {
|
|
465
|
+
// Las versiones (proceso y fila) se toman ANTES de leer las tablas: si un
|
|
466
|
+
// sync aterriza durante la carga, esta foto queda marcada como vieja y
|
|
467
|
+
// la siguiente pregunta recarga. Una foto mixta solo puede ser más
|
|
468
|
+
// restrictiva (permisos → roles → vínculos) y dura una pregunta.
|
|
469
|
+
const version = catalogVersion;
|
|
470
|
+
const generation = this.#generation;
|
|
471
|
+
const dbVersion = knownVersion ?? (await this.#readVersion());
|
|
472
|
+
const permissions = await this.#sql('catalog.permissions', () => db.from('authz_permissions').select('uuid', 'slug', 'assignable_at'));
|
|
473
|
+
const roles = await this.#sql('catalog.roles', () => db.from('authz_roles').select('uuid', 'slug', 'scope_type', 'owner_scope_key', 'rank'));
|
|
474
|
+
const links = await this.#sql('catalog.links', () => db.from('authz_role_permissions').select('role_uuid', 'permission_uuid'));
|
|
475
|
+
const view = buildCatalogView(permissions, roles, links, systemClock().getTime(), dbVersion);
|
|
476
|
+
this.#view = view;
|
|
477
|
+
this.#version = version;
|
|
478
|
+
this.#loadedGeneration = generation;
|
|
479
|
+
// La ventana se mide con el reloj monótono; `loadedAt` es de pared (informativo).
|
|
480
|
+
this.#checkedAt = this.#now();
|
|
481
|
+
return view;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Clave `(slug, scopeType)` de un rol con un separador NO imprimible
|
|
486
|
+
* (`\u001f`, escrito como escape a propósito: un carácter invisible en el
|
|
487
|
+
* código ya costó un bug): `a:b`+`c` y `a`+`b:c` no pueden colisionar.
|
|
488
|
+
*/
|
|
489
|
+
function roleKey(slug, scopeType) {
|
|
490
|
+
return `${slug}\u001f${scopeType}`;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* `assignable_at` tal como viene de la base: `NULL` = cualquier nivel; si
|
|
494
|
+
* no, un JSON con una lista no vacía de tipos de scope válidos. Otra cosa
|
|
495
|
+
* (una edición a mano) es catálogo corrupto: 500 `E_AUTHZ_INTERNAL`, nunca
|
|
496
|
+
* «cualquiera» (sería relajar una restricción en silencio) ni «ninguno»
|
|
497
|
+
* disfrazado de dato.
|
|
498
|
+
*/
|
|
499
|
+
export function parseAssignableAt(slug, raw) {
|
|
500
|
+
if (raw === null || raw === undefined)
|
|
501
|
+
return null;
|
|
502
|
+
let parsed = raw;
|
|
503
|
+
if (typeof raw === 'string') {
|
|
504
|
+
try {
|
|
505
|
+
parsed = JSON.parse(raw);
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
parsed = undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((t) => typeof t === 'string' && isValidScopeType(t))) {
|
|
512
|
+
throw new AuthorizationInternalError(`authz_permissions.assignable_at del permiso '${slug}' no es una lista JSON no vacía de tipos de scope válidos ` +
|
|
513
|
+
`(llegó ${typeof raw === 'string' ? raw : typeof raw}); corrige la fila o vuelve a sincronizar el catálogo.`);
|
|
514
|
+
}
|
|
515
|
+
return Object.freeze([...new Set(parsed)]);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* `authz_roles.owner_scope_key` tal como viene de la base: `global` o la
|
|
519
|
+
* clave de un scope que NO es la raíz. Cualquier otra cosa es catálogo
|
|
520
|
+
* corrupto (500 `E_AUTHZ_INTERNAL`), nunca un default silencioso.
|
|
521
|
+
*
|
|
522
|
+
* `'app'` en particular (3D · N3, auditor V7): la raíz está SIEMPRE en toda
|
|
523
|
+
* cadena, así que un rol con ese owner sería visible en todas partes — un
|
|
524
|
+
* global disfrazado que `syncAuthzCatalog` no gobierna. La API lo impide
|
|
525
|
+
* (`#assertOwnerScope`); aquí se cierra la fila escrita a mano.
|
|
526
|
+
*/
|
|
527
|
+
function ownerOf(row) {
|
|
528
|
+
const owner = row.owner_scope_key;
|
|
529
|
+
const valid = typeof owner === 'string' && (owner === GLOBAL_OWNER_KEY || scopeFromKey(owner)?.uuid != null);
|
|
530
|
+
if (!valid) {
|
|
531
|
+
throw new AuthorizationInternalError(`authz_roles.owner_scope_key del rol '${row.slug}@${row.scope_type}' (${row.uuid}) no es una clave de owner ` +
|
|
532
|
+
`(llegó ${typeof owner === 'string' ? `'${owner}'` : owner === null ? 'null' : typeof owner}); se espera ` +
|
|
533
|
+
`'${GLOBAL_OWNER_KEY}' o '<tipo>|<uuid>' de un scope que no sea la raíz. Corrige la fila o aplica la migración 2.2.`);
|
|
534
|
+
}
|
|
535
|
+
return owner;
|
|
536
|
+
}
|
|
537
|
+
function buildCatalogView(permissions, roles, links, loadedAt, version) {
|
|
538
|
+
const permissionBySlug = new Map();
|
|
539
|
+
const slugByPermissionUuid = new Map();
|
|
540
|
+
for (const p of permissions) {
|
|
541
|
+
permissionBySlug.set(p.slug, Object.freeze({ uuid: p.uuid, assignableAt: parseAssignableAt(p.slug, p.assignable_at) }));
|
|
542
|
+
slugByPermissionUuid.set(p.uuid, p.slug);
|
|
543
|
+
}
|
|
544
|
+
// Por `(slug, scopeType)` puede haber VARIOS roles (owners distintos, 3B):
|
|
545
|
+
// la lista guarda primero el global (si lo hay) y luego los locales.
|
|
546
|
+
const rolesByKey = new Map();
|
|
547
|
+
const roleByUuid = new Map();
|
|
548
|
+
const rolesByLevel = new Map();
|
|
549
|
+
const rolesByOwner = new Map();
|
|
550
|
+
let topGlobalRank = 0;
|
|
551
|
+
for (const r of roles) {
|
|
552
|
+
const owner = ownerOf(r);
|
|
553
|
+
const rank = Number(r.rank ?? 0);
|
|
554
|
+
const role = Object.freeze({
|
|
555
|
+
uuid: r.uuid,
|
|
556
|
+
slug: r.slug,
|
|
557
|
+
scopeType: r.scope_type,
|
|
558
|
+
owner,
|
|
559
|
+
rank: Number.isFinite(rank) ? rank : 0,
|
|
560
|
+
});
|
|
561
|
+
const key = roleKey(r.slug, r.scope_type);
|
|
562
|
+
if (!rolesByKey.has(key))
|
|
563
|
+
rolesByKey.set(key, []);
|
|
564
|
+
if (owner === GLOBAL_OWNER_KEY) {
|
|
565
|
+
rolesByKey.get(key).unshift(role);
|
|
566
|
+
if (role.rank > topGlobalRank)
|
|
567
|
+
topGlobalRank = role.rank;
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
rolesByKey.get(key).push(role);
|
|
571
|
+
}
|
|
572
|
+
roleByUuid.set(r.uuid, role);
|
|
573
|
+
if (!rolesByLevel.has(r.scope_type))
|
|
574
|
+
rolesByLevel.set(r.scope_type, []);
|
|
575
|
+
rolesByLevel.get(r.scope_type).push(role);
|
|
576
|
+
if (owner !== GLOBAL_OWNER_KEY) {
|
|
577
|
+
if (!rolesByOwner.has(owner))
|
|
578
|
+
rolesByOwner.set(owner, []);
|
|
579
|
+
rolesByOwner.get(owner).push(role);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Un vínculo cuyo rol o permiso no existe (FK rota fuera del sync) no
|
|
583
|
+
// concede nada: se ignora, igual que lo ignoraría el join SQL.
|
|
584
|
+
const grantingByPermission = new Map();
|
|
585
|
+
const permissionsByRole = new Map();
|
|
586
|
+
for (const link of links) {
|
|
587
|
+
const role = roleByUuid.get(link.role_uuid);
|
|
588
|
+
if (!role)
|
|
589
|
+
continue;
|
|
590
|
+
const slug = slugByPermissionUuid.get(link.permission_uuid);
|
|
591
|
+
if (slug !== undefined) {
|
|
592
|
+
if (!permissionsByRole.has(link.role_uuid))
|
|
593
|
+
permissionsByRole.set(link.role_uuid, new Set());
|
|
594
|
+
permissionsByRole.get(link.role_uuid).add(slug);
|
|
595
|
+
}
|
|
596
|
+
let byLevel = grantingByPermission.get(link.permission_uuid);
|
|
597
|
+
if (!byLevel) {
|
|
598
|
+
byLevel = new Map();
|
|
599
|
+
grantingByPermission.set(link.permission_uuid, byLevel);
|
|
600
|
+
}
|
|
601
|
+
let list = byLevel.get(role.scopeType);
|
|
602
|
+
if (!list) {
|
|
603
|
+
list = [];
|
|
604
|
+
byLevel.set(role.scopeType, list);
|
|
605
|
+
}
|
|
606
|
+
list.push({ slug: role.slug, uuid: link.role_uuid, scopeType: role.scopeType, owner: role.owner });
|
|
607
|
+
}
|
|
608
|
+
const EMPTY_SET = new Set();
|
|
609
|
+
const permissionSlugs = Object.freeze([...permissionBySlug.keys()]);
|
|
610
|
+
return {
|
|
611
|
+
permission: (slug) => permissionBySlug.get(slug) ?? null,
|
|
612
|
+
role: (slug, scopeType) => (rolesByKey.get(roleKey(slug, scopeType)) ?? []).find((r) => r.owner === GLOBAL_OWNER_KEY) ?? null,
|
|
613
|
+
roleVisible: (slug, scopeType, chainKeys) => {
|
|
614
|
+
const named = rolesByKey.get(roleKey(slug, scopeType));
|
|
615
|
+
if (!named)
|
|
616
|
+
return null;
|
|
617
|
+
const owners = new Set(chainKeys);
|
|
618
|
+
const visible = named.filter((role) => isRoleVisibleWith(role, owners));
|
|
619
|
+
if (visible.length === 0)
|
|
620
|
+
return null;
|
|
621
|
+
if (visible.length > 1) {
|
|
622
|
+
// 3D · M1: fail-closed. Se nombran uuid y owner de los que SON
|
|
623
|
+
// visibles en esta cadena —y solo esos (3E · Q2): el llamante ya
|
|
624
|
+
// puede verlos, así que no hay fuga de otro árbol—, que es lo que le
|
|
625
|
+
// permite direccionar por `{ uuid }`.
|
|
626
|
+
//
|
|
627
|
+
// 3E · Q1 (auditor A5): el mensaje aconsejaba «renombra uno de
|
|
628
|
+
// ellos» y la API PROHÍBE renombrar (`updateScopedRole` solo cambia
|
|
629
|
+
// name/description/rank/permissions). La salida real es `{ uuid }`
|
|
630
|
+
// para seguir operando y purgar uno para deshacer la ambigüedad.
|
|
631
|
+
throw new AmbiguousRoleError(`'${slug}' (nivel '${scopeType}') es AMBIGUO aquí: hay ${visible.length} roles visibles en esta cadena ` +
|
|
632
|
+
`(${visible.map((r) => `${r.uuid} owner=${r.owner}`).join('; ')}). Un slug ya no identifica un rol: ` +
|
|
633
|
+
`pregunta por { uuid }, que sigue funcionando. Un rol local no se renombra: para deshacer la ambigüedad ` +
|
|
634
|
+
`hay que PURGAR uno (deleteScopedRole con rank suficiente, o la plataforma con driver().purgeRole). ` +
|
|
635
|
+
`authz:catalog:diff los lista (3F · S3: los ensombrecidos por autoridad NO son deriva y salen con exit 0).`);
|
|
636
|
+
}
|
|
637
|
+
return visible[0];
|
|
638
|
+
},
|
|
639
|
+
rolesNamed: (slug, scopeType) => [...(rolesByKey.get(roleKey(slug, scopeType)) ?? [])],
|
|
640
|
+
rolesOwnedBy: (ownerKey) => [...(rolesByOwner.get(ownerKey) ?? [])],
|
|
641
|
+
roleByUuid: (uuid) => roleByUuid.get(uuid) ?? null,
|
|
642
|
+
rolesFor: (scopeType, ownerKeys) => {
|
|
643
|
+
const owners = new Set(ownerKeys);
|
|
644
|
+
return (rolesByLevel.get(scopeType) ?? []).filter((r) => isRoleVisibleWith(r, owners));
|
|
645
|
+
},
|
|
646
|
+
// Copia por llamada: el llamante puede mutar lo que recibe sin tocar la foto.
|
|
647
|
+
rolesGranting: (permissionUuid) => new Map([...(grantingByPermission.get(permissionUuid) ?? new Map())].map(([k, v]) => [k, [...v]])),
|
|
648
|
+
rolePermissionsOf: (roleUuid) => new Set(permissionsByRole.get(roleUuid) ?? EMPTY_SET),
|
|
649
|
+
permissionSlug: (uuid) => slugByPermissionUuid.get(uuid) ?? null,
|
|
650
|
+
permissionSlugs,
|
|
651
|
+
topGlobalRank,
|
|
652
|
+
loadedAt,
|
|
653
|
+
version,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
//# sourceMappingURL=catalog_cache.js.map
|