@jantstack/adonis-authz 1.1.0 → 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 -58
- 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 +45 -6
- package/build/index.d.ts.map +1 -1
- package/build/index.js +37 -4
- 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 +1020 -271
- 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 +333 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +352 -0
- package/build/src/errors.js.map +1 -1
- 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
package/build/src/catalog.js
CHANGED
|
@@ -1,103 +1,753 @@
|
|
|
1
1
|
import db from '@adonisjs/lucid/services/db';
|
|
2
|
-
import { Exception } from '@adonisjs/core/exceptions';
|
|
3
2
|
import { v7 as uuidv7 } from 'uuid';
|
|
3
|
+
import { assertCatalogUuid, assertNoSlugCollisions, assertScopeType, assertValidSlug, scopeFromKey, scopeKey } from './identity.js';
|
|
4
|
+
import { CatalogConflictError, InvalidIdentityError, RoleNotAssignableAtError, UnknownPermissionError } from './errors.js';
|
|
5
|
+
import { guardSql } from './drivers/backend_guard.js';
|
|
6
|
+
import { GLOBAL_OWNER_KEY, invalidateAuthzCatalog, parseAssignableAt, withAuthzCatalogWrite } from './catalog_cache.js';
|
|
7
|
+
import { systemClock } from './clock.js';
|
|
4
8
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* existente se respeta, solo se insertan los diffs nuevos. Quitar algo del
|
|
9
|
-
* catálogo NO lo borra de DB (limpieza explícita vía migración).
|
|
10
|
-
*
|
|
11
|
-
* Lo usan el seeder del nivel app (`app_acl_seeder`) y el harness de la
|
|
12
|
-
* suite de contrato. El catálogo es metadata compartida entre drivers:
|
|
13
|
-
* un driver externo (openfga) materializa aparte solo los hechos.
|
|
9
|
+
* `assignableAt` de un permiso tal como se GUARDA (`authz_permissions.assignable_at`):
|
|
10
|
+
* JSON ordenado y sin duplicados, o `null` = cualquier nivel. Ordenado para
|
|
11
|
+
* que dos specs equivalentes sean la misma cadena (el sync compara texto).
|
|
14
12
|
*/
|
|
13
|
+
export function encodeAssignableAt(levels) {
|
|
14
|
+
if (levels === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
return JSON.stringify([...new Set(levels)].sort());
|
|
17
|
+
}
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
17
|
-
* (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* `assignable_at` tal como está EN LA BASE, normalizado a la forma en la que
|
|
20
|
+
* el spec lo codifica (3D · N5): es lo que permite que el sync y el diff
|
|
21
|
+
* comparen lo mismo. Hasta aquí el sync comparaba la cadena cruda y el diff
|
|
22
|
+
* la lista parseada, así que un valor desordenado a mano (`["unit","app"]`)
|
|
23
|
+
* salía «en sync» y el sync lo reescribía. Un valor CORRUPTO no lanza aquí:
|
|
24
|
+
* cuenta como distinto, para que el sync lo repare en vez de atragantarse
|
|
25
|
+
* (leerlo sí es 500, `parseAssignableAt`).
|
|
20
26
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
export function storedAssignableAt(slug, raw) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = parseAssignableAt(slug, raw);
|
|
30
|
+
return encodeAssignableAt(parsed ? [...parsed] : undefined);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return CORRUPT_ASSIGNABLE_AT;
|
|
26
34
|
}
|
|
27
35
|
}
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
/** Marca de `storedAssignableAt` para una fila que no se puede leer (3D · N5). */
|
|
37
|
+
export const CORRUPT_ASSIGNABLE_AT = '\u0000corrupto';
|
|
38
|
+
/**
|
|
39
|
+
* `assignable_at` de la base para el DIFF (3E · P5): una fila corrupta vale
|
|
40
|
+
* `null` (cualquier nivel) en vez de 500. El diff EXISTE para reportar filas
|
|
41
|
+
* así —`storedAssignableAt` ya las cuenta como distintas— y hasta 3E se
|
|
42
|
+
* atragantaba con `parseAssignableAt`: el comando moría con 500 justo en el
|
|
43
|
+
* despliegue que iba a repararlo. Leerlo para DECIDIR sigue siendo 500
|
|
44
|
+
* (`parseAssignableAt`, el memo).
|
|
45
|
+
*/
|
|
46
|
+
function tolerantAssignableAt(slug, raw) {
|
|
47
|
+
try {
|
|
48
|
+
return parseAssignableAt(slug, raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Un rol de nivel `scopeType` que lleva `permission` con esos `levels` (o `null`): legal, o 422 `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT`. */
|
|
55
|
+
export function assertAssignableAt(role, permission, levels) {
|
|
56
|
+
if (levels && !levels.includes(role.scopeType)) {
|
|
57
|
+
throw new RoleNotAssignableAtError(`El rol '${role.slug}' (nivel '${role.scopeType}') no puede llevar '${permission}': solo pueden llevarlo roles de ` +
|
|
58
|
+
`${levels.join(', ')} (assignableAt). Es un control de composición del catálogo, no de evaluación.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Longitud de `authz_permissions.assignable_at` (`varchar(500)` en el stub y
|
|
63
|
+
* en el espejo). El JSON codificado tiene que caber (3D · N3).
|
|
64
|
+
*/
|
|
65
|
+
export const ASSIGNABLE_AT_MAX = 500;
|
|
66
|
+
/** Deadline de cada consulta del catálogo (D15); configurable por `timeoutMs`. */
|
|
67
|
+
export const DEFAULT_CATALOG_TIMEOUT_MS = 5_000;
|
|
68
|
+
/**
|
|
69
|
+
* Valida la gramática del catálogo entero ANTES de tocar la base: formato,
|
|
70
|
+
* longitud publicable en FGA, reservados, familias de prefijos y colisiones
|
|
71
|
+
* tras codificar (`docs:write` / `docs_write`). Un catálogo inválido no
|
|
72
|
+
* escribe nada.
|
|
73
|
+
*/
|
|
74
|
+
function assertCatalogGrammar(catalog) {
|
|
75
|
+
const levelsBySlug = new Map();
|
|
76
|
+
for (const perm of catalog.permissions) {
|
|
30
77
|
assertValidSlug('permiso', perm.slug);
|
|
31
|
-
|
|
78
|
+
// `assignableAt` (3B · B5): omitido = cualquier nivel; si viene, una
|
|
79
|
+
// lista no vacía de tipos de scope válidos (mismo formato que un
|
|
80
|
+
// `scope_type`). `[]` sería «ningún rol puede llevarlo»: un permiso
|
|
81
|
+
// muerto disfrazado de restricción, se rechaza.
|
|
82
|
+
if (perm.assignableAt !== undefined) {
|
|
83
|
+
if (!Array.isArray(perm.assignableAt) || perm.assignableAt.length === 0) {
|
|
84
|
+
throw new InvalidIdentityError(`assignableAt del permiso '${perm.slug}' inválido: se esperaba una lista no vacía de tipos de scope (u omitirlo) y llegó ` +
|
|
85
|
+
`${Array.isArray(perm.assignableAt) ? '[]' : typeof perm.assignableAt}`);
|
|
86
|
+
}
|
|
87
|
+
for (const level of perm.assignableAt)
|
|
88
|
+
assertScopeType(level);
|
|
89
|
+
// 3D · N3 (auditor V7): `authz_permissions.assignable_at` es
|
|
90
|
+
// `varchar(500)`. Un JSON que no cabe se trunca en un MySQL no
|
|
91
|
+
// estricto y `parseAssignableAt` respondería 500 en CADA `view()` —un
|
|
92
|
+
// corte de servicio total por un dato de config—. Se rechaza al
|
|
93
|
+
// escribir, que es donde el operador puede arreglarlo.
|
|
94
|
+
const encoded = encodeAssignableAt([...perm.assignableAt]);
|
|
95
|
+
if (encoded.length > ASSIGNABLE_AT_MAX) {
|
|
96
|
+
throw new InvalidIdentityError(`assignableAt del permiso '${perm.slug}' no cabe en authz_permissions.assignable_at ` +
|
|
97
|
+
`(${encoded.length} caracteres, máximo ${ASSIGNABLE_AT_MAX}): declara menos niveles o nombres más cortos.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
levelsBySlug.set(perm.slug, perm.assignableAt ? Object.freeze([...perm.assignableAt]) : null);
|
|
101
|
+
}
|
|
102
|
+
for (const role of catalog.roles) {
|
|
32
103
|
assertValidSlug('rol', role.slug);
|
|
104
|
+
// El nivel del rol es identidad de scope (minúsculas, ≤ 20, sin
|
|
105
|
+
// separadores): lo que no pase aquí no puede llegar a `scope_type` (E4).
|
|
106
|
+
assertScopeType(role.scopeType);
|
|
107
|
+
// El uuid fijo del spec es la identidad del rol en ambos drivers y viaja
|
|
108
|
+
// en los ids de binding de FGA (3A · A1): canónico y en minúsculas, o 422.
|
|
109
|
+
if (role.uuid !== undefined)
|
|
110
|
+
assertCatalogUuid(`rol '${role.slug}'`, role.uuid);
|
|
111
|
+
for (const slug of role.permissions) {
|
|
112
|
+
assertValidSlug('permiso', slug);
|
|
113
|
+
// Composición dentro del spec (B5); los permisos de OTRO catálogo se
|
|
114
|
+
// contrastan con la base dentro de la transacción.
|
|
115
|
+
assertAssignableAt(role, slug, levelsBySlug.get(slug) ?? null);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
assertNoSlugCollisions('permiso', catalog.permissions.map((p) => p.slug));
|
|
119
|
+
assertNoSlugCollisions('rol', catalog.roles.map((r) => r.slug));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Sincroniza un catálogo (roles + permisos + vínculos) a las tablas `authz_*`.
|
|
123
|
+
*
|
|
124
|
+
* Idempotente y transaccional. Permisos y roles se crean si faltan (nunca se
|
|
125
|
+
* borran: la limpieza de un rol o permiso retirado es una decisión explícita
|
|
126
|
+
* del consumidor, porque arrastra asignaciones). Los vínculos rol→permiso
|
|
127
|
+
* son la excepción: con `prune: 'links'` (default) el spec MANDA sobre los
|
|
128
|
+
* roles que declara. Antes el sync era solo aditivo y quitar un permiso de
|
|
129
|
+
* un rol en el config no lo quitaba de ningún entorno — la única fuente de
|
|
130
|
+
* verdad visible mentía sobre los permisos efectivos (L0.9, N1).
|
|
131
|
+
*
|
|
132
|
+
* Un rol que concede un permiso que no existe ni en este spec ni en la base
|
|
133
|
+
* (sincronizado por otro catálogo) es 422 `E_AUTHZ_UNKNOWN_PERMISSION`:
|
|
134
|
+
* antes se saltaba en silencio.
|
|
135
|
+
*
|
|
136
|
+
* Lo usan el seeder del nivel app, `authz:catalog:sync` y el harness de la
|
|
137
|
+
* suite de contrato. El catálogo es metadata compartida entre drivers: un
|
|
138
|
+
* driver externo (openfga) materializa aparte solo los hechos.
|
|
139
|
+
*/
|
|
140
|
+
export async function syncAuthzCatalog(catalog, options = {}) {
|
|
141
|
+
assertCatalogGrammar(catalog);
|
|
142
|
+
const prune = options.prune ?? 'links';
|
|
143
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
|
|
144
|
+
// Cada consulta con deadline y fallo clasificado (D15). `fn` devuelve el
|
|
145
|
+
// builder sin ejecutar (nada de `.first()`, que ejecuta al instante).
|
|
146
|
+
const sql = (operation, fn) => guardSql('catalog', operation, timeoutMs, fn);
|
|
147
|
+
const one = async (operation, fn) => (await sql(operation, () => fn().limit(1)))[0] ?? null;
|
|
33
148
|
// Todo o nada. Un fallo a mitad (una constraint, la conexión) dejaba el
|
|
34
149
|
// catálogo escrito a medias: roles sin sus permisos, es decir holders que
|
|
35
|
-
// "tienen" un rol que no concede nada.
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
150
|
+
// "tienen" un rol que no concede nada. El guard exterior clasifica lo que
|
|
151
|
+
// falle al abrir o confirmar la transacción.
|
|
152
|
+
//
|
|
153
|
+
// La versión compartida (`authz_catalog_version`) sube DENTRO de la
|
|
154
|
+
// transacción y como su ÚLTIMA sentencia (2D · F1, 2E · H2), por el mismo
|
|
155
|
+
// camino que cualquier escritura a mano: `withAuthzCatalogWrite`. Los memos
|
|
156
|
+
// de todos los procesos la contrastan antes de servir y recargan. Al salir
|
|
157
|
+
// —bien o mal— se invalida además el memo de este proceso (2A): un sync
|
|
158
|
+
// que confirmó tiene que verse en la siguiente pregunta también con
|
|
159
|
+
// `everyMs`, y uno que falló al confirmar no se sabe si confirmó. Recargar
|
|
160
|
+
// de más es gratis; servir un catálogo viejo no.
|
|
161
|
+
try {
|
|
162
|
+
return await syncInTransaction(catalog, prune, sql, one, timeoutMs);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
invalidateAuthzCatalog();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function syncInTransaction(catalog, prune, sql, one, timeoutMs) {
|
|
169
|
+
const report = { shadowedByGlobal: [], assignableAtViolations: [] };
|
|
170
|
+
await sql('sync', () => withAuthzCatalogWrite(async (trx) => {
|
|
171
|
+
// 0. Colisión tras codificar también contra lo que YA hay en la base:
|
|
172
|
+
// `docs:write` de otro catálogo y `docs_write` de este serían UNA
|
|
173
|
+
// relación FGA (D3). Dentro de la transacción, para verlo consistente.
|
|
174
|
+
const stored = await sql('sync.permissions', () => trx.from('authz_permissions').select('slug'));
|
|
175
|
+
assertNoSlugCollisions('permiso', [
|
|
176
|
+
...catalog.permissions.map((p) => p.slug),
|
|
177
|
+
...stored.map((p) => p.slug),
|
|
178
|
+
]);
|
|
179
|
+
// 1. Permisos: upsert por slug; `assignable_at` manda el config (B5).
|
|
39
180
|
for (const perm of catalog.permissions) {
|
|
40
|
-
const existing = await trx.from('authz_permissions').where('slug', perm.slug).
|
|
41
|
-
|
|
181
|
+
const existing = await one('sync.permission', () => trx.from('authz_permissions').where('slug', perm.slug).select('uuid', 'assignable_at'));
|
|
182
|
+
const assignableAt = encodeAssignableAt(perm.assignableAt);
|
|
183
|
+
if (existing) {
|
|
184
|
+
if (storedAssignableAt(perm.slug, existing.assignable_at) !== assignableAt) {
|
|
185
|
+
await sql('sync.permission.levels', () => trx.from('authz_permissions').where('uuid', existing.uuid).update({ assignable_at: assignableAt, updated_at: systemClock() }));
|
|
186
|
+
}
|
|
42
187
|
continue;
|
|
43
|
-
|
|
188
|
+
}
|
|
189
|
+
await sql('sync.permission.insert', () => trx.table('authz_permissions').insert({
|
|
44
190
|
uuid: uuidv7(),
|
|
45
191
|
slug: perm.slug,
|
|
46
192
|
description: perm.description ?? null,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
193
|
+
assignable_at: assignableAt,
|
|
194
|
+
created_at: systemClock(),
|
|
195
|
+
updated_at: systemClock(),
|
|
196
|
+
}));
|
|
50
197
|
}
|
|
51
|
-
|
|
198
|
+
// Los permisos que los roles referencian, estén en este spec o vengan de
|
|
199
|
+
// otro catálogo ya sincronizado — con sus niveles (B5): un rol de este
|
|
200
|
+
// spec tampoco puede llevar un permiso ajeno fuera de su nivel.
|
|
201
|
+
const referenced = new Set(catalog.permissions.map((p) => p.slug));
|
|
202
|
+
for (const role of catalog.roles)
|
|
203
|
+
for (const slug of role.permissions)
|
|
204
|
+
referenced.add(slug);
|
|
205
|
+
const dbPerms = await sql('sync.referenced', () => trx
|
|
52
206
|
.from('authz_permissions')
|
|
53
|
-
.whereIn('slug',
|
|
54
|
-
.select('uuid', 'slug');
|
|
207
|
+
.whereIn('slug', [...referenced])
|
|
208
|
+
.select('uuid', 'slug', 'assignable_at'));
|
|
55
209
|
const permUuidBySlug = new Map(dbPerms.map((p) => [p.slug, p.uuid]));
|
|
56
|
-
|
|
210
|
+
const levelsBySlug = new Map(dbPerms.map((p) => [p.slug, parseAssignableAt(p.slug, p.assignable_at)]));
|
|
57
211
|
for (const role of catalog.roles) {
|
|
58
|
-
const
|
|
212
|
+
for (const slug of role.permissions) {
|
|
213
|
+
if (!permUuidBySlug.has(slug))
|
|
214
|
+
throw new UnknownPermissionError(slug);
|
|
215
|
+
assertAssignableAt(role, slug, levelsBySlug.get(slug) ?? null);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// 2. Roles: upsert por (slug, scope_type) entre los GLOBALES (3B · B6).
|
|
219
|
+
// Un rol LOCAL con ese (slug, scope_type) es una colisión: el spec
|
|
220
|
+
// no puede ocupar un nombre que un tenant ya usa (dentro de ese
|
|
221
|
+
// tenant habría dos roles con el mismo nombre) — 422, nada escrito.
|
|
222
|
+
// 3E · P1 b (auditor A1): un rol LOCAL homónimo ya NO aborta el catálogo
|
|
223
|
+
// entero. Un tenant con rank 2 tumbaba el despliegue de la plataforma
|
|
224
|
+
// para siempre —y con él roles nuevos que no tenían nada que ver—. Los
|
|
225
|
+
// globales GANAN: se escribe el global, el local afectado se REPORTA
|
|
226
|
+
// (`shadowedByGlobal`) y el perjuicio queda en quien ocupó el nombre:
|
|
227
|
+
// con M1 la ambigüedad es fail-closed, así que sus rutas por slug pasan
|
|
228
|
+
// a 422 y le queda `{ uuid }` o pedir la purga. `defineScopedRole`
|
|
229
|
+
// sigue rechazando las colisiones hacia ARRIBA (3F · S3), y hacia
|
|
230
|
+
// ABAJO ensombrecer exige superar en RANGO al ensombrecido (3G · W3):
|
|
231
|
+
// la autoridad no es solo posición en el árbol.
|
|
232
|
+
//
|
|
233
|
+
// En UNA consulta, no una por rol (3F · T2, auditor N6): esto corre con
|
|
234
|
+
// el cerrojo de `authz_catalog_version` sostenido, y un deploy con
|
|
235
|
+
// cientos de roles alargaba la sección crítica hasta hacer probable el
|
|
236
|
+
// 503 `E_AUTHZ_BACKEND_TIMEOUT` de los `defineScopedRole` concurrentes.
|
|
237
|
+
const localesHomonimas = catalog.roles.length
|
|
238
|
+
? await sql('sync.role.locals', () => trx
|
|
239
|
+
.from('authz_roles')
|
|
240
|
+
.whereIn('slug', [...new Set(catalog.roles.map((r) => r.slug))])
|
|
241
|
+
.whereNot('owner_scope_key', GLOBAL_OWNER_KEY)
|
|
242
|
+
.select('uuid', 'slug', 'scope_type', 'owner_scope_key'))
|
|
243
|
+
: [];
|
|
244
|
+
for (const role of catalog.roles) {
|
|
245
|
+
const locals = localesHomonimas.filter((row) => row.slug === role.slug && String(row.scope_type) === role.scopeType);
|
|
246
|
+
for (const local of locals) {
|
|
247
|
+
report.shadowedByGlobal.push({
|
|
248
|
+
uuid: String(local.uuid),
|
|
249
|
+
slug: role.slug,
|
|
250
|
+
scopeType: role.scopeType,
|
|
251
|
+
owner: String(local.owner_scope_key),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const existing = await one('sync.role', () => trx
|
|
59
255
|
.from('authz_roles')
|
|
60
256
|
.where('slug', role.slug)
|
|
61
257
|
.where('scope_type', role.scopeType)
|
|
62
|
-
.
|
|
258
|
+
.where('owner_scope_key', GLOBAL_OWNER_KEY)
|
|
259
|
+
.select('uuid', 'rank'));
|
|
63
260
|
const roleUuid = existing?.uuid ?? role.uuid ?? uuidv7();
|
|
64
261
|
if (!existing) {
|
|
65
|
-
await trx.table('authz_roles').insert({
|
|
262
|
+
await sql('sync.role.insert', () => trx.table('authz_roles').insert({
|
|
66
263
|
uuid: roleUuid,
|
|
67
264
|
slug: role.slug,
|
|
68
265
|
name: role.name ?? role.slug,
|
|
69
266
|
description: role.description ?? null,
|
|
70
267
|
scope_type: role.scopeType,
|
|
71
268
|
rank: role.rank ?? 0,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
269
|
+
owner_scope_key: GLOBAL_OWNER_KEY,
|
|
270
|
+
created_at: systemClock(),
|
|
271
|
+
updated_at: systemClock(),
|
|
272
|
+
}));
|
|
75
273
|
}
|
|
76
274
|
else if (role.rank !== undefined && existing.rank !== role.rank) {
|
|
77
|
-
// El rank es metadata de policy: el config manda
|
|
78
|
-
|
|
79
|
-
await trx.from('authz_roles').where('uuid', roleUuid).update({ rank: role.rank });
|
|
275
|
+
// El rank es metadata de policy: el config manda.
|
|
276
|
+
await sql('sync.role.rank', () => trx.from('authz_roles').where('uuid', roleUuid).update({ rank: role.rank }));
|
|
80
277
|
}
|
|
81
|
-
// 3. Vínculos rol→permiso:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
278
|
+
// 3. Vínculos rol→permiso: el spec manda para ESTE rol.
|
|
279
|
+
const wanted = new Set(role.permissions.map((slug) => permUuidBySlug.get(slug)));
|
|
280
|
+
const current = await sql('sync.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).select('uuid', 'permission_uuid'));
|
|
281
|
+
const linked = new Set(current.map((l) => l.permission_uuid));
|
|
282
|
+
for (const permUuid of wanted) {
|
|
283
|
+
if (linked.has(permUuid))
|
|
85
284
|
continue;
|
|
86
|
-
|
|
87
|
-
.from('authz_role_permissions')
|
|
88
|
-
.where('role_uuid', roleUuid)
|
|
89
|
-
.where('permission_uuid', permUuid)
|
|
90
|
-
.first();
|
|
91
|
-
if (linked)
|
|
92
|
-
continue;
|
|
93
|
-
await trx.table('authz_role_permissions').insert({
|
|
285
|
+
await sql('sync.link.insert', () => trx.table('authz_role_permissions').insert({
|
|
94
286
|
uuid: uuidv7(),
|
|
95
287
|
role_uuid: roleUuid,
|
|
96
288
|
permission_uuid: permUuid,
|
|
97
|
-
created_at:
|
|
289
|
+
created_at: systemClock(),
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
if (prune === 'links') {
|
|
293
|
+
const stale = current.filter((l) => !wanted.has(l.permission_uuid));
|
|
294
|
+
if (stale.length) {
|
|
295
|
+
await sql('sync.link.prune', () => trx
|
|
296
|
+
.from('authz_role_permissions')
|
|
297
|
+
.whereIn('uuid', stale.map((l) => l.uuid))
|
|
298
|
+
.delete());
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// 3.5 Revalidación de composición contra el `assignableAt` que acaba
|
|
303
|
+
// de mandar el config (3E · P6): los roles DEL SPEC ya se validan
|
|
304
|
+
// arriba (un spec incoherente consigo mismo es 422 y no escribe
|
|
305
|
+
// nada), pero los que ya estaban —LOCALES de los tenants y
|
|
306
|
+
// globales de otro catálogo— llevaban el permiso desde antes y
|
|
307
|
+
// nadie los miraba: estrechar `assignableAt` entraba a medias y en
|
|
308
|
+
// silencio. No se les quita el vínculo (lo asignado sigue
|
|
309
|
+
// concediendo, invariante 1): se REPORTAN, como `shadowedByGlobal`.
|
|
310
|
+
const limited = catalog.permissions.filter((p) => p.assignableAt);
|
|
311
|
+
if (limited.length) {
|
|
312
|
+
const levelsOf = new Map(limited.map((p) => [p.slug, new Set(p.assignableAt)]));
|
|
313
|
+
const links = await sql('sync.revalidate', () => trx
|
|
314
|
+
.from('authz_role_permissions')
|
|
315
|
+
.join('authz_roles', 'authz_roles.uuid', 'authz_role_permissions.role_uuid')
|
|
316
|
+
.join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
|
|
317
|
+
.whereIn('authz_permissions.slug', limited.map((p) => p.slug))
|
|
318
|
+
.select('authz_roles.uuid as role_uuid', 'authz_roles.slug as role_slug', 'authz_roles.scope_type as scope_type', 'authz_roles.owner_scope_key as owner_scope_key', 'authz_permissions.slug as permission_slug'));
|
|
319
|
+
for (const link of links) {
|
|
320
|
+
const levels = levelsOf.get(String(link.permission_slug));
|
|
321
|
+
if (!levels || levels.has(String(link.scope_type)))
|
|
322
|
+
continue;
|
|
323
|
+
report.assignableAtViolations.push({
|
|
324
|
+
role: {
|
|
325
|
+
uuid: String(link.role_uuid),
|
|
326
|
+
slug: String(link.role_slug),
|
|
327
|
+
scopeType: String(link.scope_type),
|
|
328
|
+
owner: String(link.owner_scope_key),
|
|
329
|
+
},
|
|
330
|
+
permission: String(link.permission_slug),
|
|
98
331
|
});
|
|
99
332
|
}
|
|
100
333
|
}
|
|
101
|
-
|
|
334
|
+
// 4. La versión compartida la sube `withAuthzCatalogWrite` al salir de
|
|
335
|
+
// aquí, como última sentencia: o se confirma todo (catálogo nuevo +
|
|
336
|
+
// versión nueva) o nada.
|
|
337
|
+
}, { driver: 'catalog', timeoutMs }));
|
|
338
|
+
return report;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Compara un spec con lo que hay en `authz_*`, sin escribir. Igual que el
|
|
342
|
+
* sync, solo mira los roles del spec: lo ajeno (otro catálogo) no es una
|
|
343
|
+
* diferencia. Un diff limpio significa que `syncAuthzCatalog(spec)` sería un
|
|
344
|
+
* no-op.
|
|
345
|
+
*/
|
|
346
|
+
export async function diffAuthzCatalog(catalog, options = {}) {
|
|
347
|
+
assertCatalogGrammar(catalog);
|
|
348
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
|
|
349
|
+
const sql = (operation, fn) => guardSql('catalog', operation, timeoutMs, fn);
|
|
350
|
+
const diff = {
|
|
351
|
+
missingPermissions: [],
|
|
352
|
+
missingRoles: [],
|
|
353
|
+
missingLinks: [],
|
|
354
|
+
extraLinks: [],
|
|
355
|
+
rankMismatches: [],
|
|
356
|
+
assignableAtMismatches: [],
|
|
357
|
+
scopedRoles: [],
|
|
358
|
+
ambiguousRoles: [],
|
|
359
|
+
shadowedByGlobal: [],
|
|
360
|
+
shadowedByAncestor: [],
|
|
361
|
+
assignableAtViolations: [],
|
|
362
|
+
};
|
|
363
|
+
const dbPerms = await sql('diff.permissions', () => db.from('authz_permissions').select('uuid', 'slug', 'assignable_at'));
|
|
364
|
+
// Lo que el sync rechazaría, el diff lo dice antes (D3).
|
|
365
|
+
assertNoSlugCollisions('permiso', [
|
|
366
|
+
...catalog.permissions.map((p) => p.slug),
|
|
367
|
+
...dbPerms.map((p) => p.slug),
|
|
368
|
+
]);
|
|
369
|
+
const permUuidBySlug = new Map(dbPerms.map((p) => [p.slug, p.uuid]));
|
|
370
|
+
const permSlugByUuid = new Map(dbPerms.map((p) => [p.uuid, p.slug]));
|
|
371
|
+
// Tolerante con la fila corrupta (3E · P5): el diff la REPORTA, no muere.
|
|
372
|
+
const levelsBySlug = new Map(dbPerms.map((p) => [p.slug, tolerantAssignableAt(p.slug, p.assignable_at)]));
|
|
373
|
+
const storedBySlug = new Map(dbPerms.map((p) => [p.slug, storedAssignableAt(p.slug, p.assignable_at)]));
|
|
374
|
+
for (const perm of catalog.permissions) {
|
|
375
|
+
if (!permUuidBySlug.has(perm.slug)) {
|
|
376
|
+
diff.missingPermissions.push(perm.slug);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const expected = encodeAssignableAt(perm.assignableAt);
|
|
380
|
+
const actual = levelsBySlug.get(perm.slug) ?? null;
|
|
381
|
+
// La MISMA normalización que el sync (3D · N5): lo que el diff dice «en
|
|
382
|
+
// sync» tiene que ser exactamente lo que el sync dejaría sin tocar.
|
|
383
|
+
const stored = storedBySlug.get(perm.slug) ?? null;
|
|
384
|
+
if (expected !== stored) {
|
|
385
|
+
const corrupt = stored === CORRUPT_ASSIGNABLE_AT;
|
|
386
|
+
diff.assignableAtMismatches.push({
|
|
387
|
+
permission: perm.slug,
|
|
388
|
+
expected: expected ? JSON.parse(expected) : null,
|
|
389
|
+
actual: corrupt ? null : actual ? [...actual].sort() : null,
|
|
390
|
+
...(corrupt ? { corrupt: true } : {}),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Un rol del spec que lleve un permiso de OTRO catálogo fuera de su nivel:
|
|
395
|
+
// el sync lo rechazaría (B5), el diff lo dice antes.
|
|
396
|
+
for (const role of catalog.roles) {
|
|
397
|
+
for (const slug of role.permissions) {
|
|
398
|
+
if (!catalog.permissions.some((p) => p.slug === slug))
|
|
399
|
+
assertAssignableAt(role, slug, levelsBySlug.get(slug) ?? null);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Roles locales (3B): se listan como propios de un scope; y un global del
|
|
403
|
+
// spec con el nombre de uno de ellos es la misma colisión que en el sync.
|
|
404
|
+
const locals = await sql('diff.locals', () => db.from('authz_roles').whereNot('owner_scope_key', GLOBAL_OWNER_KEY).select('slug', 'scope_type', 'owner_scope_key'));
|
|
405
|
+
for (const local of locals) {
|
|
406
|
+
diff.scopedRoles.push({ slug: local.slug, scopeType: local.scope_type, owner: local.owner_scope_key });
|
|
407
|
+
// 3E · P1 b: ya no es un 422 (el sync escribe el global y ensombrece al
|
|
408
|
+
// local). Es deriva que hay que ver ANTES del deploy, no una excepción
|
|
409
|
+
// que impide ver el resto del informe.
|
|
410
|
+
const clash = catalog.roles.find((r) => r.slug === local.slug && r.scopeType === local.scope_type);
|
|
411
|
+
if (clash) {
|
|
412
|
+
diff.shadowedByGlobal.push({ slug: local.slug, scopeType: local.scope_type, owner: local.owner_scope_key });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
diff.assignableAtViolations = await findAssignableAtViolations(sql, catalog);
|
|
416
|
+
// 3F · S3: los homónimos visibles en una misma cadena se clasifican por
|
|
417
|
+
// AUTORIDAD; solo lo que la autoridad no ordena es deriva.
|
|
418
|
+
const homonimos = await classifyHomonyms(sql, options.resolveChain);
|
|
419
|
+
diff.ambiguousRoles = homonimos.ambiguousRoles;
|
|
420
|
+
diff.shadowedByAncestor = homonimos.shadowedByAncestor;
|
|
421
|
+
for (const shadow of homonimos.shadowedByGlobal) {
|
|
422
|
+
if (diff.shadowedByGlobal.some((s) => s.slug === shadow.slug && s.scopeType === shadow.scopeType && s.owner === shadow.owner))
|
|
423
|
+
continue;
|
|
424
|
+
diff.shadowedByGlobal.push(shadow);
|
|
425
|
+
}
|
|
426
|
+
for (const role of catalog.roles) {
|
|
427
|
+
const existing = (await sql('diff.role', () => db
|
|
428
|
+
.from('authz_roles')
|
|
429
|
+
.where('slug', role.slug)
|
|
430
|
+
.where('scope_type', role.scopeType)
|
|
431
|
+
.where('owner_scope_key', GLOBAL_OWNER_KEY)
|
|
432
|
+
.select('uuid', 'rank')
|
|
433
|
+
.limit(1)))[0];
|
|
434
|
+
if (!existing) {
|
|
435
|
+
diff.missingRoles.push({ slug: role.slug, scopeType: role.scopeType });
|
|
436
|
+
for (const permission of role.permissions) {
|
|
437
|
+
diff.missingLinks.push({ role: role.slug, scopeType: role.scopeType, permission });
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (role.rank !== undefined && existing.rank !== role.rank) {
|
|
442
|
+
diff.rankMismatches.push({
|
|
443
|
+
role: role.slug,
|
|
444
|
+
scopeType: role.scopeType,
|
|
445
|
+
expected: role.rank,
|
|
446
|
+
actual: existing.rank,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const current = await sql('diff.links', () => db.from('authz_role_permissions').where('role_uuid', existing.uuid).select('permission_uuid'));
|
|
450
|
+
const linked = new Set(current.map((l) => permSlugByUuid.get(l.permission_uuid) ?? l.permission_uuid));
|
|
451
|
+
for (const permission of role.permissions) {
|
|
452
|
+
if (!linked.has(permission)) {
|
|
453
|
+
diff.missingLinks.push({ role: role.slug, scopeType: role.scopeType, permission });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const permission of [...linked].sort()) {
|
|
457
|
+
if (!role.permissions.includes(permission)) {
|
|
458
|
+
diff.extraLinks.push({ role: role.slug, scopeType: role.scopeType, permission });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return diff;
|
|
463
|
+
}
|
|
464
|
+
export function catalogInSync(diff) {
|
|
465
|
+
return (diff.missingPermissions.length === 0 &&
|
|
466
|
+
diff.missingRoles.length === 0 &&
|
|
467
|
+
diff.missingLinks.length === 0 &&
|
|
468
|
+
diff.extraLinks.length === 0 &&
|
|
469
|
+
diff.rankMismatches.length === 0 &&
|
|
470
|
+
diff.assignableAtMismatches.length === 0 &&
|
|
471
|
+
diff.ambiguousRoles.length === 0 &&
|
|
472
|
+
diff.assignableAtViolations.length === 0);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Vínculos rol→permiso vivos que el `assignableAt` del spec ya no admite
|
|
476
|
+
* (3E · P6). Mira TODOS los roles de la base (los del spec los rechaza
|
|
477
|
+
* `assertAssignableAt` antes, con 422): los que quedan son locales de los
|
|
478
|
+
* tenants y globales de otro catálogo, justo los que el sync no revalidaba.
|
|
479
|
+
*/
|
|
480
|
+
async function findAssignableAtViolations(sql, catalog) {
|
|
481
|
+
const limited = catalog.permissions.filter((p) => p.assignableAt);
|
|
482
|
+
if (!limited.length)
|
|
483
|
+
return [];
|
|
484
|
+
const levelsOf = new Map(limited.map((p) => [p.slug, new Set(p.assignableAt)]));
|
|
485
|
+
const links = await sql('diff.assignableAt', () => db
|
|
486
|
+
.from('authz_role_permissions')
|
|
487
|
+
.join('authz_roles', 'authz_roles.uuid', 'authz_role_permissions.role_uuid')
|
|
488
|
+
.join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
|
|
489
|
+
.whereIn('authz_permissions.slug', limited.map((p) => p.slug))
|
|
490
|
+
.select('authz_roles.slug as role_slug', 'authz_roles.scope_type as scope_type', 'authz_roles.owner_scope_key as owner_scope_key', 'authz_permissions.slug as permission_slug'));
|
|
491
|
+
const found = [];
|
|
492
|
+
for (const link of links) {
|
|
493
|
+
const levels = levelsOf.get(String(link.permission_slug));
|
|
494
|
+
if (!levels || levels.has(String(link.scope_type)))
|
|
495
|
+
continue;
|
|
496
|
+
found.push({
|
|
497
|
+
role: String(link.role_slug),
|
|
498
|
+
scopeType: String(link.scope_type),
|
|
499
|
+
owner: String(link.owner_scope_key),
|
|
500
|
+
permission: String(link.permission_slug),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
return found;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* `(slug, nivel)` con dos o más roles visibles a la vez desde una misma
|
|
507
|
+
* cadena (3D · M2 d), clasificados por AUTORIDAD (3F · S3): global > local
|
|
508
|
+
* de un ancestro > local de un descendiente. Un global + cualquier local
|
|
509
|
+
* siempre conviven (el global se ve en todas partes) y el global gana; dos
|
|
510
|
+
* locales solo conviven si un owner está en la cadena del otro —eso exige el
|
|
511
|
+
* árbol del consumidor, así que sin `resolveChain` la pareja no se juzga (y
|
|
512
|
+
* el informe lo dice)— y gana el ancestro. Lo que queda en `ambiguousRoles`
|
|
513
|
+
* es lo que ninguna regla ordena: dos owners que se declaran ancestro el uno
|
|
514
|
+
* del otro (un `resolveChain` con un ciclo o contradictorio). Eso es deriva.
|
|
515
|
+
*/
|
|
516
|
+
async function classifyHomonyms(sql, resolveChain) {
|
|
517
|
+
const rows = await sql('diff.ambiguous', () => db.from('authz_roles').select('slug', 'scope_type', 'owner_scope_key'));
|
|
518
|
+
const byName = new Map();
|
|
519
|
+
for (const row of rows) {
|
|
520
|
+
const key = `${row.slug}\u001f${row.scope_type}`;
|
|
521
|
+
if (!byName.has(key))
|
|
522
|
+
byName.set(key, []);
|
|
523
|
+
byName.get(key).push(String(row.owner_scope_key));
|
|
524
|
+
}
|
|
525
|
+
const chains = new Map();
|
|
526
|
+
const chainOf = async (ownerKey) => {
|
|
527
|
+
if (chains.has(ownerKey))
|
|
528
|
+
return chains.get(ownerKey);
|
|
529
|
+
const owner = scopeFromKey(ownerKey);
|
|
530
|
+
let keys = null;
|
|
531
|
+
if (owner && resolveChain) {
|
|
532
|
+
const chain = await resolveChain(owner);
|
|
533
|
+
keys = chain ? chain.map(scopeKey) : null;
|
|
534
|
+
}
|
|
535
|
+
chains.set(ownerKey, keys);
|
|
536
|
+
return keys;
|
|
537
|
+
};
|
|
538
|
+
const result = {
|
|
539
|
+
ambiguousRoles: [],
|
|
540
|
+
shadowedByGlobal: [],
|
|
541
|
+
shadowedByAncestor: [],
|
|
542
|
+
};
|
|
543
|
+
for (const [key, owners] of byName) {
|
|
544
|
+
if (owners.length < 2)
|
|
545
|
+
continue;
|
|
546
|
+
const [slug, scopeType] = key.split('\u001f');
|
|
547
|
+
const locals = owners.filter((o) => o !== GLOBAL_OWNER_KEY);
|
|
548
|
+
if (owners.length > locals.length) {
|
|
549
|
+
// Hay un global: convive con TODOS los locales homónimos y GANA.
|
|
550
|
+
for (const owner of locals)
|
|
551
|
+
result.shadowedByGlobal.push({ slug, scopeType, owner });
|
|
552
|
+
}
|
|
553
|
+
// 3G · X3 (auditor P5 b): la pareja de LOCALES se clasifica igual haya
|
|
554
|
+
// global o no. Hasta aquí un global en el grupo hacía `continue` y con
|
|
555
|
+
// eso dejaba de detectarse una pareja de locales CONTRADICTORIA (dos
|
|
556
|
+
// owners que se declaran ancestro el uno del otro), que es la única
|
|
557
|
+
// deriva de verdad de esta clasificación: un caso ciego nuevo.
|
|
558
|
+
for (const a of locals) {
|
|
559
|
+
for (const b of locals) {
|
|
560
|
+
if (a === b)
|
|
561
|
+
continue;
|
|
562
|
+
// `a` está en la cadena de `b`: `a` es su ancestro y lo ensombrece.
|
|
563
|
+
if (!(await chainOf(b))?.includes(a))
|
|
564
|
+
continue;
|
|
565
|
+
if ((await chainOf(a))?.includes(b)) {
|
|
566
|
+
// Y `b` en la de `a`: el árbol se contradice y nadie manda.
|
|
567
|
+
const owners2 = [a, b].sort();
|
|
568
|
+
if (!result.ambiguousRoles.some((r) => r.slug === slug && r.scopeType === scopeType && r.owners.join() === owners2.join())) {
|
|
569
|
+
result.ambiguousRoles.push({ slug, scopeType, owners: owners2 });
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
result.shadowedByAncestor.push({ slug, scopeType, owner: b, shadowedBy: a });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
/** Líneas legibles del diff (vacío si está en sync). */
|
|
580
|
+
export function formatCatalogDiff(diff) {
|
|
581
|
+
const lines = [];
|
|
582
|
+
const link = (l) => `${l.role}@${l.scopeType} → ${l.permission}`;
|
|
583
|
+
for (const slug of diff.missingPermissions)
|
|
584
|
+
lines.push(`permiso ausente en la base: ${slug}`);
|
|
585
|
+
for (const role of diff.missingRoles)
|
|
586
|
+
lines.push(`rol ausente en la base: ${role.slug}@${role.scopeType}`);
|
|
587
|
+
for (const l of diff.missingLinks)
|
|
588
|
+
lines.push(`vínculo ausente en la base: ${link(l)}`);
|
|
589
|
+
for (const l of diff.extraLinks)
|
|
590
|
+
lines.push(`vínculo SOBRANTE en la base (privilegio zombi): ${link(l)}`);
|
|
591
|
+
for (const r of diff.rankMismatches) {
|
|
592
|
+
lines.push(`rank distinto: ${r.role}@${r.scopeType} spec=${r.expected} base=${r.actual}`);
|
|
593
|
+
}
|
|
594
|
+
const levels = (l) => (l ? l.join(',') : 'cualquiera');
|
|
595
|
+
for (const a of diff.assignableAtMismatches) {
|
|
596
|
+
if (a.corrupt) {
|
|
597
|
+
lines.push(`assignableAt CORRUPTO en la base: ${a.permission} (spec=${levels(a.expected)}); authz_permissions.assignable_at ` +
|
|
598
|
+
`no es una lista JSON de niveles y el memo lo lee con 500: sincroniza para repararlo`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
lines.push(`assignableAt distinto: ${a.permission} spec=${levels(a.expected)} base=${levels(a.actual)}`);
|
|
602
|
+
}
|
|
603
|
+
for (const v of diff.assignableAtViolations) {
|
|
604
|
+
lines.push(`vínculo fuera de assignableAt: ${v.role}@${v.scopeType} (owner ${v.owner}) → ${v.permission} — ` +
|
|
605
|
+
`el config ya no admite ese nivel; el sync no lo borra (lo asignado sigue concediendo): arréglalo tú`);
|
|
606
|
+
}
|
|
607
|
+
for (const a of diff.ambiguousRoles) {
|
|
608
|
+
lines.push(`rol AMBIGUO que la autoridad no ordena (los dos owners se declaran ancestro del otro): ${a.slug}@${a.scopeType} ` +
|
|
609
|
+
`owners=${a.owners.join(', ')} — toda ruta por slug ahí responde 422 E_AUTHZ_AMBIGUOUS_ROLE; se opera por ` +
|
|
610
|
+
`{ uuid } y se deshace PURGANDO uno (un rol local no se renombra) o arreglando el árbol`);
|
|
611
|
+
}
|
|
612
|
+
return lines;
|
|
613
|
+
}
|
|
614
|
+
/** Líneas informativas del diff (no son diferencias): los roles locales, propios de un scope. */
|
|
615
|
+
export function formatScopedRoles(diff) {
|
|
616
|
+
return diff.scopedRoles.map((r) => `rol local (propio de ${r.owner}): ${r.slug}@${r.scopeType}`);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Líneas informativas del diff (3F · S3): los roles locales ENSOMBRECIDOS
|
|
620
|
+
* por una definición más autorizada —un global, o un local de un ancestro—.
|
|
621
|
+
* No son deriva: es el orden de autoridad funcionando. Se listan porque
|
|
622
|
+
* mientras duren, ese slug es 422 `E_AUTHZ_AMBIGUOUS_ROLE` en la cadena de
|
|
623
|
+
* su owner y hay que operar por `{ uuid }` (o purgar uno de los dos).
|
|
624
|
+
*/
|
|
625
|
+
export function formatShadowedRoles(diff) {
|
|
626
|
+
const lines = [];
|
|
627
|
+
for (const s of diff.shadowedByGlobal) {
|
|
628
|
+
lines.push(`rol local ENSOMBRECIDO por un global homónimo: ${s.slug}@${s.scopeType} (owner ${s.owner}) — el global gana; ` +
|
|
629
|
+
`en la cadena de ese owner el slug pasa a 422 E_AUTHZ_AMBIGUOUS_ROLE (para TODOS, la plataforma incluida: ` +
|
|
630
|
+
`se direcciona por { uuid }) hasta que se purgue uno`);
|
|
631
|
+
}
|
|
632
|
+
for (const s of diff.shadowedByAncestor) {
|
|
633
|
+
lines.push(`rol local ENSOMBRECIDO por el de un ancestro: ${s.slug}@${s.scopeType} (owner ${s.owner}, ensombrecido por ` +
|
|
634
|
+
`${s.shadowedBy}) — el ancestro gana; en la cadena de ${s.owner} el slug pasa a 422 E_AUTHZ_AMBIGUOUS_ROLE ` +
|
|
635
|
+
`(se direcciona por { uuid }) hasta que se purgue uno`);
|
|
636
|
+
}
|
|
637
|
+
return lines;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Resuelve todos los catálogos y comprueba que ningún rol `(slug, scopeType)`
|
|
641
|
+
* ni permiso aparezca en dos de ellos (422 `E_AUTHZ_CATALOG_CONFLICT`). Se
|
|
642
|
+
* hace ANTES de tocar la base: el sync de un catálogo poda los vínculos de
|
|
643
|
+
* los roles que declara, así que dos catálogos con el mismo rol se pisarían
|
|
644
|
+
* y el último en el orden ganaría en silencio (D3). Un rol pertenece a
|
|
645
|
+
* exactamente un catálogo. También valida la gramática de cada uno.
|
|
646
|
+
*/
|
|
647
|
+
async function resolveDisjointCatalogs(catalogs) {
|
|
648
|
+
const specs = [];
|
|
649
|
+
for (const source of catalogs)
|
|
650
|
+
specs.push(await source());
|
|
651
|
+
for (const spec of specs)
|
|
652
|
+
assertCatalogGrammar(spec);
|
|
653
|
+
const roleOwner = new Map();
|
|
654
|
+
const permissionOwner = new Map();
|
|
655
|
+
const conflicts = [];
|
|
656
|
+
for (const [index, spec] of specs.entries()) {
|
|
657
|
+
for (const role of spec.roles) {
|
|
658
|
+
const id = `${role.slug}@${role.scopeType}`;
|
|
659
|
+
const owner = roleOwner.get(id);
|
|
660
|
+
if (owner !== undefined)
|
|
661
|
+
conflicts.push(`rol ${id} (catálogos #${owner + 1} y #${index + 1})`);
|
|
662
|
+
else
|
|
663
|
+
roleOwner.set(id, index);
|
|
664
|
+
}
|
|
665
|
+
for (const perm of spec.permissions) {
|
|
666
|
+
const owner = permissionOwner.get(perm.slug);
|
|
667
|
+
if (owner !== undefined) {
|
|
668
|
+
conflicts.push(`permiso ${perm.slug} (catálogos #${owner + 1} y #${index + 1})`);
|
|
669
|
+
}
|
|
670
|
+
else
|
|
671
|
+
permissionOwner.set(perm.slug, index);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (conflicts.length) {
|
|
675
|
+
throw new CatalogConflictError(`Catálogos en conflicto: ${conflicts.join('; ')}. Un rol (slug + scopeType) y un permiso ` +
|
|
676
|
+
`pertenecen a exactamente un catálogo: el sync del segundo podaría los vínculos del primero.`);
|
|
677
|
+
}
|
|
678
|
+
return specs;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* El diff de TODOS los catálogos del config. `inSync: false` ⇒ el comando
|
|
682
|
+
* sale con código ≠ 0 (para CI). Separado del comando para poder probarlo
|
|
683
|
+
* sin un kernel de ace.
|
|
684
|
+
*/
|
|
685
|
+
export async function runCatalogDiff(catalogs, options = {}) {
|
|
686
|
+
const lines = [];
|
|
687
|
+
let inSync = true;
|
|
688
|
+
const specs = await resolveDisjointCatalogs(catalogs);
|
|
689
|
+
// Los roles locales (3B) no son deriva del config y no dependen del spec:
|
|
690
|
+
// se toman del PRIMER diff que ya se calcula (3E · Q6; hasta aquí el
|
|
691
|
+
// comando repetía el diff entero —todas sus consultas— solo para
|
|
692
|
+
// extraerlos, y sus líneas salían indentadas DENTRO del bloque de
|
|
693
|
+
// diferencias del último catálogo, como si fueran suyas).
|
|
694
|
+
let scoped = [];
|
|
695
|
+
let shadowed = [];
|
|
696
|
+
for (const [index, spec] of specs.entries()) {
|
|
697
|
+
const diff = await diffAuthzCatalog(spec, options);
|
|
698
|
+
if (index === 0)
|
|
699
|
+
shadowed = formatShadowedRoles(diff);
|
|
700
|
+
if (index === 0)
|
|
701
|
+
scoped = formatScopedRoles(diff);
|
|
702
|
+
if (catalogInSync(diff)) {
|
|
703
|
+
lines.push(`catálogo #${index + 1}: en sync`);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
inSync = false;
|
|
707
|
+
lines.push(`catálogo #${index + 1}: DIFERENCIAS`);
|
|
708
|
+
for (const line of formatCatalogDiff(diff))
|
|
709
|
+
lines.push(` ${line}`);
|
|
710
|
+
}
|
|
711
|
+
if (shadowed.length) {
|
|
712
|
+
lines.push('roles locales ENSOMBRECIDOS por una definición más autorizada (no son deriva: 3F · S3):');
|
|
713
|
+
for (const line of shadowed)
|
|
714
|
+
lines.push(` ${line}`);
|
|
715
|
+
// 3G · X3 (auditor P5): que un tenant no pueda dejar en rojo el gate de
|
|
716
|
+
// CI de la plataforma es la decisión de 3F · S3, pero el efecto es que
|
|
717
|
+
// NADIE se entera por CI de que las rutas por slug de un subárbol están
|
|
718
|
+
// muertas. `--fail-on-shadows` es el opt-in de quien sí quiere enterarse.
|
|
719
|
+
if (options.failOnShadows) {
|
|
720
|
+
inSync = false;
|
|
721
|
+
lines.push(' (--fail-on-shadows: los ensombrecidos cuentan como deriva en ESTA ejecución)');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (scoped.length) {
|
|
725
|
+
lines.push('roles locales (no son deriva del config):');
|
|
726
|
+
for (const line of scoped)
|
|
727
|
+
lines.push(` ${line}`);
|
|
728
|
+
if (!options.resolveChain) {
|
|
729
|
+
lines.push(' (sin scopes.resolveChain no se puede juzgar si dos roles locales homónimos son visibles en la misma cadena; ' +
|
|
730
|
+
'la pareja global+local sí se detecta)');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return { inSync, lines };
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Sincroniza todos los catálogos del config, en orden (lo que hace
|
|
737
|
+
* `authz:catalog:sync`). Devuelve cuántos y lo que hay que DECIR (3E · P1 b
|
|
738
|
+
* / P6): el comando lo imprime como aviso — reportar y seguir, nunca romper
|
|
739
|
+
* en silencio.
|
|
740
|
+
*/
|
|
741
|
+
export async function syncCatalogs(catalogs, options = {}) {
|
|
742
|
+
let count = 0;
|
|
743
|
+
const shadowedByGlobal = [];
|
|
744
|
+
const assignableAtViolations = [];
|
|
745
|
+
for (const spec of await resolveDisjointCatalogs(catalogs)) {
|
|
746
|
+
const report = await syncAuthzCatalog(spec, options);
|
|
747
|
+
shadowedByGlobal.push(...report.shadowedByGlobal);
|
|
748
|
+
assignableAtViolations.push(...report.assignableAtViolations);
|
|
749
|
+
count += 1;
|
|
750
|
+
}
|
|
751
|
+
return { count, shadowedByGlobal, assignableAtViolations };
|
|
102
752
|
}
|
|
103
753
|
//# sourceMappingURL=catalog.js.map
|