@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
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import db from '@adonisjs/lucid/services/db';
|
|
2
|
-
import { Exception } from '@adonisjs/core/exceptions';
|
|
3
2
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
-
import {
|
|
3
|
+
import { APP_SCOPE_TYPE } from '../types.js';
|
|
4
|
+
import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
|
|
5
|
+
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
6
|
+
import { AuthorizationBackendError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, RoleNotVisibleError, UnknownPermissionError, UnknownRoleError, } from '../errors.js';
|
|
7
|
+
import { assertKnownScope, canonicalScope, guardSql, resolveChain, rootOnlyResolver } from './backend_guard.js';
|
|
8
|
+
import { assertAssignableAt } from '../catalog.js';
|
|
9
|
+
import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog_cache.js';
|
|
10
|
+
import { isClock, systemClock } from '../clock.js';
|
|
11
|
+
import { sqlExpiryCodec } from './sql_expiry.js';
|
|
5
12
|
/**
|
|
6
13
|
* UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
|
|
7
14
|
* permite que el unique de assignments/denies cubra también el nivel app
|
|
@@ -10,11 +17,194 @@ import { APP_SCOPE } from '../types.js';
|
|
|
10
17
|
*/
|
|
11
18
|
export const APP_SCOPE_DB_UUID = '00000000-0000-0000-0000-000000000000';
|
|
12
19
|
function toDbScopeUuid(scope) {
|
|
20
|
+
// Defensa en profundidad (L0.10/L0.15): es el único punto donde el scope
|
|
21
|
+
// se serializa a columnas, así que aquí se garantiza que `app` no lleve
|
|
22
|
+
// uuid y que ningún otro tipo use el centinela de la raíz.
|
|
23
|
+
assertScope(scope);
|
|
13
24
|
return scope.uuid ?? APP_SCOPE_DB_UUID;
|
|
14
25
|
}
|
|
15
26
|
function fromDbScopeUuid(uuid) {
|
|
16
27
|
return uuid === APP_SCOPE_DB_UUID ? null : uuid;
|
|
17
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Clave de agrupación por scope de las filas de `authz_*` (3D · N5: UNA sola
|
|
31
|
+
* codificación en el driver). No es `scopeKey` de `identity.ts`: aquí el
|
|
32
|
+
* uuid es el de la COLUMNA (con el centinela de la raíz), porque es lo que
|
|
33
|
+
* devuelven las consultas. `\u001f` separa: ningún componente lo admite.
|
|
34
|
+
*/
|
|
35
|
+
function dbScopeKey(scope) {
|
|
36
|
+
return `${scope.type}\u001f${toDbScopeUuid(scope)}`;
|
|
37
|
+
}
|
|
38
|
+
function rowScopeKey(row) {
|
|
39
|
+
return `${row.scope_type}\u001f${row.scope_uuid}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
|
|
43
|
+
* `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
|
|
44
|
+
* pero todos son locales a otro contenedor; `E_AUTHZ_UNKNOWN_ROLE` si no hay
|
|
45
|
+
* ninguno. Compartido por ambos drivers (3B · B2).
|
|
46
|
+
*/
|
|
47
|
+
export function visibleRoleOrFail(catalog, slug, scope, chainKeys) {
|
|
48
|
+
const visible = catalog.roleVisible(slug, scope.type, chainKeys);
|
|
49
|
+
if (visible)
|
|
50
|
+
return visible;
|
|
51
|
+
const named = catalog.rolesNamed(slug, scope.type);
|
|
52
|
+
if (named.length === 0)
|
|
53
|
+
throw new UnknownRoleError(slug, scope.type);
|
|
54
|
+
// 3E · Q2 (auditor A6): NO se nombran los owners. Si ninguno es visible
|
|
55
|
+
// desde este scope, todos son locales a un contenedor que NO está en la
|
|
56
|
+
// cadena preguntada: imprimir sus claves regalaba a un tenant los
|
|
57
|
+
// identificadores de scope de otro (un 422 es lo que un framework devuelve
|
|
58
|
+
// tal cual al cliente). Lo que el llamante necesita saber —que el nombre
|
|
59
|
+
// existe pero no aquí— cabe sin ellos.
|
|
60
|
+
throw new RoleNotVisibleError(`El rol '${slug}' (nivel '${scope.type}') no existe en ${scope.type}:${scope.uuid ?? ''}: hay ${named.length} ` +
|
|
61
|
+
`${named.length === 1 ? 'rol' : 'roles'} con ese nombre en el catálogo, ${named.length === 1 ? 'local' : 'locales'} ` +
|
|
62
|
+
`a un scope que no está en la cadena de este. Un rol local solo se asigna en su owner o en sus descendientes.`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* EL rol al que apunta un `RoleQuery` en un scope concreto (3D · M1), para
|
|
66
|
+
* las rutas que direccionan un rol exacto (`grant`, `listSubjects`):
|
|
67
|
+
*
|
|
68
|
+
* - `{ uuid }`: resolución EXACTA, sin ambigüedad posible. Fuera del
|
|
69
|
+
* catálogo ⇒ 422 `E_AUTHZ_UNKNOWN_ROLE`; declarado para otro nivel o con
|
|
70
|
+
* el owner fuera de la cadena ⇒ 422 `E_AUTHZ_ROLE_NOT_VISIBLE` (no existe
|
|
71
|
+
* AQUÍ, que es lo mismo que decía la ruta por slug).
|
|
72
|
+
* - slug (con nivel opcional): `visibleRoleOrFail`, que ahora falla cerrado
|
|
73
|
+
* si hay más de un homónimo visible (422 `E_AUTHZ_AMBIGUOUS_ROLE`).
|
|
74
|
+
*
|
|
75
|
+
* Compartido por ambos drivers.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveRoleQuery(catalog, role, scope, chainKeys) {
|
|
78
|
+
const query = normalizeRoleQuery(role);
|
|
79
|
+
if (query.uuid !== undefined) {
|
|
80
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
81
|
+
if (!declared)
|
|
82
|
+
throw new UnknownRoleError(query.uuid);
|
|
83
|
+
if (declared.scopeType !== scope.type || !isRoleVisibleWith(declared, chainKeys)) {
|
|
84
|
+
// 3E · Q2: el uuid de un rol de OTRO árbol no devuelve su slug ni su
|
|
85
|
+
// owner (sería una sonda: pruebo uuids y aprendo el catálogo ajeno).
|
|
86
|
+
// Cuando el owner SÍ está en la cadena, el rol es del llamante y
|
|
87
|
+
// nombrarlo le dice exactamente qué pasa (nivel equivocado).
|
|
88
|
+
throw new RoleNotVisibleError(isRoleVisibleWith(declared, chainKeys)
|
|
89
|
+
? `El rol '${declared.slug}' (${declared.uuid}, owner ${declared.owner}) está declarado para el nivel ` +
|
|
90
|
+
`'${declared.scopeType}' y no existe en ${scope.type}:${scope.uuid ?? ''}.`
|
|
91
|
+
: `El rol ${declared.uuid} no existe en ${scope.type}:${scope.uuid ?? ''}: es local a un scope que no está ` +
|
|
92
|
+
`en la cadena de ese scope.`);
|
|
93
|
+
}
|
|
94
|
+
return declared;
|
|
95
|
+
}
|
|
96
|
+
if (query.scopeType !== undefined && query.scopeType !== scope.type) {
|
|
97
|
+
// `{ slug, scopeType }` apunta a un rol de OTRO nivel: en un scope de tipo
|
|
98
|
+
// `scope.type` ese rol no existe (un rol vive en su nivel, D5/L0.6).
|
|
99
|
+
throw new UnknownRoleError(query.slug, scope.type);
|
|
100
|
+
}
|
|
101
|
+
return visibleRoleOrFail(catalog, query.slug, scope, chainKeys);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* EL rol al que apunta un `RoleQuery` en un scope, para el camino de LECTURA
|
|
105
|
+
* (`listSubjects`): `null` si el catálogo no declara ninguno que exista ahí
|
|
106
|
+
* (invariante 5: desconocido no lanza). La AMBIGÜEDAD sí lanza (3D · M1):
|
|
107
|
+
* elegir uno de dos homónimos era enumerar los holders del otro tenant.
|
|
108
|
+
*/
|
|
109
|
+
export function visibleRoleFor(catalog, query, scope, chainKeys) {
|
|
110
|
+
if (query.uuid !== undefined) {
|
|
111
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
112
|
+
if (!declared || declared.scopeType !== scope.type || !isRoleVisibleWith(declared, chainKeys))
|
|
113
|
+
return null;
|
|
114
|
+
return declared;
|
|
115
|
+
}
|
|
116
|
+
if (query.scopeType !== undefined && query.scopeType !== scope.type)
|
|
117
|
+
return null;
|
|
118
|
+
return catalog.roleVisible(query.slug, scope.type, chainKeys);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Los roles a los que un `RoleQuery` puede referirse en un scope, para
|
|
122
|
+
* `revoke` (3D · M1): por uuid, ESE rol (422 si el catálogo no lo declara);
|
|
123
|
+
* por slug, TODOS los homónimos `(slug, nivel)` — quitar nunca concede y el
|
|
124
|
+
* scope puede no existir ya para el árbol (D8), así que no se resuelve la
|
|
125
|
+
* ambigüedad: se quitan todos. 422 `E_AUTHZ_UNKNOWN_ROLE` si no hay ninguno.
|
|
126
|
+
*/
|
|
127
|
+
export function rolesToRevoke(catalog, role, scope) {
|
|
128
|
+
const query = normalizeRoleQuery(role);
|
|
129
|
+
if (query.uuid !== undefined) {
|
|
130
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
131
|
+
if (!declared)
|
|
132
|
+
throw new UnknownRoleError(query.uuid);
|
|
133
|
+
return [declared];
|
|
134
|
+
}
|
|
135
|
+
const level = query.scopeType ?? scope.type;
|
|
136
|
+
const named = catalog.rolesNamed(query.slug, level);
|
|
137
|
+
if (named.length === 0)
|
|
138
|
+
throw new UnknownRoleError(query.slug, level);
|
|
139
|
+
return named;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Los niveles de la cadena donde una asignación de ESE rol contaría, para
|
|
143
|
+
* `hasRole` (3D · M1): los scopes del tipo del rol desde los que el rol es
|
|
144
|
+
* visible por owner. `null` = el catálogo no declara nada que responda (la
|
|
145
|
+
* membresía es `false`, nunca un throw: invariante 5).
|
|
146
|
+
*/
|
|
147
|
+
export function hasRoleTargets(catalog, role, chain) {
|
|
148
|
+
const query = normalizeRoleQuery(role);
|
|
149
|
+
const keysFrom = chainKeysFrom(chain);
|
|
150
|
+
if (query.uuid !== undefined) {
|
|
151
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
152
|
+
if (!declared)
|
|
153
|
+
return [];
|
|
154
|
+
return chain.flatMap((s, i) => s.type === declared.scopeType && isRoleVisibleWith(declared, keysFrom[i]) ? [{ scope: s, roleUuid: declared.uuid }] : []);
|
|
155
|
+
}
|
|
156
|
+
return chain.flatMap((s, i) => {
|
|
157
|
+
if (query.scopeType !== undefined && s.type !== query.scopeType)
|
|
158
|
+
return [];
|
|
159
|
+
const declared = catalog.roleVisible(query.slug, s.type, keysFrom[i]);
|
|
160
|
+
return declared ? [{ scope: s, roleUuid: declared.uuid }] : [];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Control de COMPOSICIÓN en la escritura (3B · B5, defensa en profundidad):
|
|
165
|
+
* un rol de nivel L que lleve un permiso cuyo `assignableAt` no incluye L no
|
|
166
|
+
* se asigna (422 `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT`). El sync y
|
|
167
|
+
* `defineScopedRole` ya lo rechazan al componer; aquí se cierra el vínculo
|
|
168
|
+
* escrito a mano. `authorize` NUNCA lo mira: lo ya asignado sigue
|
|
169
|
+
* concediendo (invariante 1).
|
|
170
|
+
*/
|
|
171
|
+
export function assertRoleAssignableAt(catalog, role) {
|
|
172
|
+
// La regla es UNA (3D · N5): la misma que aplican `syncAuthzCatalog` y
|
|
173
|
+
// `defineScopedRole` al componer (`assertAssignableAt`, `catalog.ts`).
|
|
174
|
+
for (const permission of catalog.rolePermissionsOf(role.uuid)) {
|
|
175
|
+
assertAssignableAt(role, permission, catalog.permission(permission)?.assignableAt ?? null);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* EL rol de un uuid, si EXISTE en un scope de nivel `scopeType` cuya cadena
|
|
180
|
+
* tiene esas claves: declarado para ese nivel (D5/L0.6) y global o con el
|
|
181
|
+
* owner en la cadena desde ahí (3B · B2). `null` si no. Es la regla de
|
|
182
|
+
* visibilidad por uuid, en UN sitio (3D · N5): la usan `listRoles`,
|
|
183
|
+
* `rolesInChain`, `listRoleScopes`/`listScopes` (`visibleAt`) en `database`
|
|
184
|
+
* y `declaredRole` en `openfga`.
|
|
185
|
+
*/
|
|
186
|
+
export function declaredRoleAt(catalog, roleUuid, scopeType, chainKeys) {
|
|
187
|
+
const declared = catalog.roleByUuid(roleUuid);
|
|
188
|
+
if (!declared || declared.scopeType !== scopeType)
|
|
189
|
+
return null;
|
|
190
|
+
return isRoleVisibleWith(declared, chainKeys) ? declared : null;
|
|
191
|
+
}
|
|
192
|
+
export function whereScopeIn(query, column, scopes, intent) {
|
|
193
|
+
if (scopes.length === 0) {
|
|
194
|
+
if (intent === 'write') {
|
|
195
|
+
throw new AuthorizationInternalError(`whereScopeIn: una escritura sin scopes no tiene destino (columna '${column}')`);
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return query.where((outer) => {
|
|
200
|
+
for (const s of scopes) {
|
|
201
|
+
outer.orWhere((inner) => {
|
|
202
|
+
inner.where(`${column}_type`, s.type).where(`${column}_uuid`, toDbScopeUuid(s));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
18
208
|
/**
|
|
19
209
|
* Driver `database`: motor de autorización propio sobre las tablas `authz_*`.
|
|
20
210
|
* Default autosuficiente del chasis (cero piezas extra en el appliance).
|
|
@@ -24,237 +214,612 @@ function fromDbScopeUuid(uuid) {
|
|
|
24
214
|
* permiso vía su rol. La cadena es `[scope, ...ancestros]` — herencia solo
|
|
25
215
|
* hacia abajo. Solo SQL estándar vía query builder (regla M6: nada
|
|
26
216
|
* específico de un motor).
|
|
217
|
+
*
|
|
218
|
+
* Toda consulta pasa por `sql()`: un fallo del cliente SQL (conexión, tabla,
|
|
219
|
+
* deadline) sale como `AuthorizationBackendError` 503, nunca como el error
|
|
220
|
+
* crudo de knex (L0.11).
|
|
27
221
|
*/
|
|
28
222
|
export class DatabaseAuthorizationDriver {
|
|
29
223
|
/**
|
|
30
224
|
* Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
|
|
31
225
|
* suyo, que conoce organizations/units, en `config/authorization.ts`).
|
|
32
|
-
*
|
|
226
|
+
* Sin él, el driver solo conoce la raíz (L0.3: ya no hay default plano).
|
|
33
227
|
*/
|
|
34
|
-
|
|
228
|
+
chainResolver;
|
|
229
|
+
timeoutMs;
|
|
230
|
+
/** Reloj de pared del driver (J1): el ÚNICO `now` de todas sus DECISIONES temporales (no de los sellos, K5). */
|
|
231
|
+
now;
|
|
232
|
+
/**
|
|
233
|
+
* Cómo viaja `expires_at` con este motor (2.5-B · K2): cadena UTC explícita
|
|
234
|
+
* en MySQL (sin depender de `timezone`/`TZ`), identidad en el resto. Se
|
|
235
|
+
* decide por dialecto en el primer uso (la conexión puede no estar lista al
|
|
236
|
+
* construir el driver).
|
|
237
|
+
*/
|
|
238
|
+
expiryCodec = null;
|
|
239
|
+
/**
|
|
240
|
+
* Memo del catálogo (2A): `findPermission`/`findRole` leen de aquí; los
|
|
241
|
+
* hechos (asignaciones, denies y el join con los vínculos) siguen en SQL en
|
|
242
|
+
* cada pregunta. Se revalida contra `authz_catalog_version` (2D · F1);
|
|
243
|
+
* `catalog.invalidate()` fuerza la recarga de ESTE memo.
|
|
244
|
+
*/
|
|
245
|
+
catalog;
|
|
35
246
|
constructor(options = {}) {
|
|
36
|
-
this.
|
|
37
|
-
|
|
247
|
+
this.chainResolver = options.resolveChain ?? rootOnlyResolver;
|
|
248
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
249
|
+
if (options.now !== undefined && !isClock(options.now)) {
|
|
250
|
+
throw new AuthorizationConfigError(`DatabaseAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
251
|
+
}
|
|
252
|
+
this.now = options.now ?? systemClock;
|
|
253
|
+
assertCatalogOptions('DatabaseAuthorizationDriver', options);
|
|
254
|
+
this.catalog =
|
|
255
|
+
options.catalog ??
|
|
256
|
+
new CatalogCache({ driver: 'database', timeoutMs: this.timeoutMs, revalidate: options.catalogRevalidate });
|
|
38
257
|
}
|
|
39
|
-
|
|
40
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Vista de este driver con OTRO resolutor de la cadena y el mismo estado
|
|
260
|
+
* (conexión, memo del catálogo, deadline). Es lo que usa
|
|
261
|
+
* `AuthorizationManager.forRequest()` para leer con un resolutor memoizado
|
|
262
|
+
* sin tocar el driver compartido: el objeto devuelto hereda del original
|
|
263
|
+
* por prototipo y solo sobrescribe el resolutor.
|
|
264
|
+
*/
|
|
265
|
+
withChainResolver(resolveChain) {
|
|
266
|
+
const view = Object.create(this);
|
|
267
|
+
view.chainResolver = resolveChain;
|
|
268
|
+
return view;
|
|
41
269
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Vista de este driver con OTRO reloj de pared (2.5 · J1) y el mismo estado
|
|
272
|
+
* (conexión, memo, deadline, resolutor). Es lo que aplica el manager con
|
|
273
|
+
* `config.clock` y lo que el juez usa para fijar el instante. Misma
|
|
274
|
+
* técnica que `withChainResolver`: herencia por prototipo, un campo.
|
|
275
|
+
*/
|
|
276
|
+
withClock(now) {
|
|
277
|
+
if (!isClock(now)) {
|
|
278
|
+
throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
|
|
279
|
+
}
|
|
280
|
+
const view = Object.create(this);
|
|
281
|
+
view.now = now;
|
|
282
|
+
return view;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* `[scope canónico, ...ancestros]`, o `null` si el scope no existe
|
|
286
|
+
* (lecturas: denegar). `chain[0]` —la fila del consumidor, no lo que
|
|
287
|
+
* escribió el llamante— es la identidad con la que se leen y escriben los
|
|
288
|
+
* hechos de ese scope (2.5-B · K1): un alias del uuid que el árbol funde
|
|
289
|
+
* con la fila real (tipo `uuid` de PG, collation `*_ci`) llega aquí ya
|
|
290
|
+
* canónico y el deny escrito canónico casa.
|
|
291
|
+
*/
|
|
292
|
+
chain(scope, operation) {
|
|
293
|
+
return resolveChain(this.chainResolver, scope, operation);
|
|
294
|
+
}
|
|
295
|
+
/** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
|
|
296
|
+
knownScope(scope, operation) {
|
|
297
|
+
return assertKnownScope(this.chainResolver, scope, operation);
|
|
51
298
|
}
|
|
52
|
-
/**
|
|
299
|
+
/** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
|
|
300
|
+
canonicalOrSelf(scope, operation) {
|
|
301
|
+
return canonicalScope(this.chainResolver, scope, operation);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
|
|
305
|
+
* devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
|
|
306
|
+
* ejecuta. Por eso no se usa `.first()` de Lucid (ejecuta al instante, sin
|
|
307
|
+
* dejar poner el timeout): `first()` de abajo hace `limit(1)` y toma la fila.
|
|
308
|
+
*/
|
|
309
|
+
sql(operation, fn) {
|
|
310
|
+
return guardSql('database', operation, this.timeoutMs, fn);
|
|
311
|
+
}
|
|
312
|
+
async first(operation, fn) {
|
|
313
|
+
const rows = await this.sql(operation, () => fn().limit(1));
|
|
314
|
+
return rows[0] ?? null;
|
|
315
|
+
}
|
|
316
|
+
get expiry() {
|
|
317
|
+
// Decidir el dialecto no cuesta ninguna consulta: una vista por
|
|
318
|
+
// prototipo (`withClock`/`withChainResolver`) hereda el codec del driver
|
|
319
|
+
// si ya lo tiene y, si no, lo decide una vez para sí.
|
|
320
|
+
this.expiryCodec ??= sqlExpiryCodec(db.connection());
|
|
321
|
+
return this.expiryCodec;
|
|
322
|
+
}
|
|
323
|
+
/** Asignación vigente: sin expiración o con expiración futura (estricta: la que vence AHORA ya no cuenta). */
|
|
53
324
|
whereActive(query, column = 'expires_at') {
|
|
325
|
+
const now = this.expiry.bind(this.now());
|
|
54
326
|
return query.where((builder) => {
|
|
55
|
-
builder.whereNull(column).orWhere(column, '>',
|
|
327
|
+
builder.whereNull(column).orWhere(column, '>', now);
|
|
56
328
|
});
|
|
57
329
|
}
|
|
330
|
+
// Catálogo desde el memo (2A): una carga por proceso/driver, no una
|
|
331
|
+
// consulta por pregunta. Un fallo de carga sale como 503, igual que antes.
|
|
58
332
|
async findPermission(slug) {
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
333
|
+
return (await this.catalog.view()).permission(slug);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Filtro «asignación VISIBLE en algún nivel de la cadena» (3B · B2), en
|
|
337
|
+
* SQL, sobre `authz_assignments as a` unida a `authz_roles as r`: por cada
|
|
338
|
+
* nivel `i`, el scope exacto Y el rol declarado para ese nivel Y (global O
|
|
339
|
+
* owner en `chain.slice(i)`). Sustituye a `whereScopeIn(chain)` en la
|
|
340
|
+
* consulta de `authorize`: mismo número de consultas, ahora con el owner
|
|
341
|
+
* decidido en la base (`owner_scope_key IN (...) OR 'global'`). La cadena
|
|
342
|
+
* viene validada (nunca vacía); L0.1 se conserva por si acaso.
|
|
343
|
+
*/
|
|
344
|
+
whereVisibleAssignmentIn(query, chain) {
|
|
345
|
+
if (chain.length === 0)
|
|
346
|
+
return null;
|
|
347
|
+
const keysFrom = chainKeysFrom(chain);
|
|
348
|
+
return query.where((outer) => {
|
|
349
|
+
chain.forEach((s, i) => {
|
|
350
|
+
outer.orWhere((level) => {
|
|
351
|
+
level
|
|
352
|
+
.where('a.scope_type', s.type)
|
|
353
|
+
.where('a.scope_uuid', toDbScopeUuid(s))
|
|
354
|
+
.where('r.scope_type', s.type)
|
|
355
|
+
.where((owner) => {
|
|
356
|
+
owner.where('r.owner_scope_key', GLOBAL_OWNER_KEY).orWhereIn('r.owner_scope_key', keysFrom[i]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
71
359
|
});
|
|
72
|
-
}
|
|
73
|
-
return role;
|
|
360
|
+
});
|
|
74
361
|
}
|
|
75
362
|
async authorize(subject, permission, scope) {
|
|
363
|
+
assertIdentity({ subject, permission, scope });
|
|
76
364
|
const perm = await this.findPermission(permission);
|
|
77
365
|
if (!perm)
|
|
78
366
|
return false;
|
|
79
|
-
const chain = await this.chain(scope);
|
|
80
|
-
|
|
367
|
+
const chain = await this.chain(scope, 'authorize');
|
|
368
|
+
if (!chain)
|
|
369
|
+
return false;
|
|
370
|
+
const deniedQuery = whereScopeIn(db
|
|
81
371
|
.from('authz_denies')
|
|
82
372
|
.where('holder_type', subject.type)
|
|
83
373
|
.where('holder_uuid', subject.uuid)
|
|
84
|
-
.where('permission_uuid', perm.uuid), 'scope', chain)
|
|
85
|
-
if (
|
|
374
|
+
.where('permission_uuid', perm.uuid), 'scope', chain, 'read');
|
|
375
|
+
if (!deniedQuery)
|
|
376
|
+
return false;
|
|
377
|
+
if (await this.first('authorize.denies', () => deniedQuery))
|
|
86
378
|
return false;
|
|
87
|
-
|
|
379
|
+
// Una asignación vigente, en la cadena, cuyo rol concede el permiso Y es
|
|
380
|
+
// visible en su nivel (global u owner ancestro-o-igual del scope de la
|
|
381
|
+
// asignación, 3B · B2). `assignableAt`/`rank` no entran: composición.
|
|
382
|
+
const grantedQuery = this.whereVisibleAssignmentIn(db
|
|
88
383
|
.from('authz_assignments as a')
|
|
89
384
|
.join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
|
|
385
|
+
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
90
386
|
.where('rp.permission_uuid', perm.uuid)
|
|
91
387
|
.where('a.holder_type', subject.type)
|
|
92
|
-
.where('a.holder_uuid', subject.uuid),
|
|
388
|
+
.where('a.holder_uuid', subject.uuid), chain);
|
|
389
|
+
if (!grantedQuery)
|
|
390
|
+
return false;
|
|
391
|
+
const granted = await this.first('authorize.assignments', () => this.whereActive(grantedQuery, 'a.expires_at'));
|
|
93
392
|
return Boolean(granted);
|
|
94
393
|
}
|
|
95
394
|
async grant(subject, role, scope, options = {}) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
395
|
+
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
396
|
+
// Se escribe bajo la identidad canónica del árbol (K1), nunca bajo la
|
|
397
|
+
// forma del llamante; y el rol tiene que EXISTIR en ese scope (3B · B2:
|
|
398
|
+
// global, o local a un ancestro-o-igual), con una composición legal (B5).
|
|
399
|
+
const chain = await this.knownScope(scope, 'grant');
|
|
400
|
+
const [target] = chain;
|
|
401
|
+
const catalog = await this.catalog.view();
|
|
402
|
+
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
403
|
+
assertRoleAssignableAt(catalog, declared);
|
|
404
|
+
const roleUuid = declared.uuid;
|
|
405
|
+
const findExisting = () => whereScopeIn(db
|
|
99
406
|
.from('authz_assignments')
|
|
100
407
|
.where('holder_type', subject.type)
|
|
101
408
|
.where('holder_uuid', subject.uuid)
|
|
102
|
-
.where('role_uuid', roleUuid), 'scope', [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
409
|
+
.where('role_uuid', roleUuid), 'scope', [target], 'write').select('uuid', this.expiry.select('expires_at'));
|
|
410
|
+
// Dos carreras posibles, ambas acotadas (2.5-B · K4): la fila que se
|
|
411
|
+
// leyó desaparece antes del UPDATE (una purga concurrente) ⇒ se vuelve a
|
|
412
|
+
// empezar como inserción; el INSERT choca con el unique (otro grant
|
|
413
|
+
// concurrente) ⇒ se relee y se refresca lo del ganador. Ninguna deja un
|
|
414
|
+
// «hecho» que no está escrito. Más de tres vueltas es contención
|
|
415
|
+
// patológica: 503, nunca un bucle.
|
|
416
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
417
|
+
const existing = await this.first('grant.find', findExisting);
|
|
418
|
+
if (existing) {
|
|
419
|
+
const refreshed = await this.refreshAssignment(existing, options.expiresAt);
|
|
420
|
+
if (refreshed)
|
|
421
|
+
return refreshed;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// No había nada: la caducidad es la pedida, o ninguna.
|
|
425
|
+
const expiresAt = options.expiresAt ?? null;
|
|
426
|
+
try {
|
|
427
|
+
await this.sql('grant.insert', () => db.table('authz_assignments').insert({
|
|
428
|
+
uuid: uuidv7(),
|
|
429
|
+
holder_type: subject.type,
|
|
430
|
+
holder_uuid: subject.uuid,
|
|
431
|
+
role_uuid: roleUuid,
|
|
432
|
+
scope_type: target.type,
|
|
433
|
+
scope_uuid: toDbScopeUuid(target),
|
|
434
|
+
expires_at: this.expiry.toDb(expiresAt),
|
|
435
|
+
// Sello de auditoría, no decisión (2.5-B · K5): reloj del sistema.
|
|
436
|
+
created_at: systemClock(),
|
|
437
|
+
}));
|
|
438
|
+
return { existed: false, expiresAt };
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
// Carrera check-then-insert: el unique (que cubre también el nivel app
|
|
442
|
+
// vía centinela) la detecta — el perdedor degrada a re-grant sobre lo
|
|
443
|
+
// que escribió el ganador (con la misma semántica de tres estados).
|
|
444
|
+
// Si no era una carrera, el fallo del insert (ya clasificado) se propaga.
|
|
445
|
+
const raced = await this.first('grant.race', findExisting);
|
|
446
|
+
if (!raced)
|
|
447
|
+
throw error;
|
|
448
|
+
const refreshed = await this.refreshAssignment(raced, options.expiresAt);
|
|
449
|
+
if (refreshed)
|
|
450
|
+
return refreshed;
|
|
451
|
+
}
|
|
122
452
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
453
|
+
throw new AuthorizationBackendError('database', 'grant', new Error(`la asignación de ${subject.type}:${subject.uuid} aparece y desaparece entre lecturas (contención); no se pudo dejar escrita`));
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Re-grant sobre una asignación existente (L0.4): omitido preserva una
|
|
457
|
+
* caducidad vigente (o revive una expirada sin caducidad), `null` la quita,
|
|
458
|
+
* `Date` la fija. Solo se escribe si la caducidad cambia de verdad; si el
|
|
459
|
+
* UPDATE no toca ninguna fila (otro proceso la borró entre la lectura y la
|
|
460
|
+
* escritura, K4) devuelve `null`: no hay «existed: true» sobre nada.
|
|
461
|
+
*/
|
|
462
|
+
async refreshAssignment(row, requested) {
|
|
463
|
+
const previous = this.expiry.fromDb(row.expires_at);
|
|
464
|
+
const expiresAt = resolveGrantExpiry(previous, requested, this.now());
|
|
465
|
+
if (!sameInstant(previous, expiresAt)) {
|
|
466
|
+
const updated = await this.sql('grant.update', () => db.from('authz_assignments').where('uuid', row.uuid).update({ expires_at: this.expiry.toDb(expiresAt) }));
|
|
467
|
+
if (Number(Array.isArray(updated) ? updated[0] : updated) === 0)
|
|
468
|
+
return null;
|
|
136
469
|
}
|
|
470
|
+
return { existed: true, previousExpiresAt: previous, expiresAt };
|
|
137
471
|
}
|
|
138
472
|
async revoke(subject, role, scope) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await this.
|
|
473
|
+
assertIdentity({ subject, role, scope });
|
|
474
|
+
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). El
|
|
475
|
+
// no-op es para una asignación inexistente de un rol válido. Se quitan
|
|
476
|
+
// los hechos de TODOS los roles con ese nombre en el scope exacto (3B):
|
|
477
|
+
// a lo sumo uno es visible ahí y los demás serían filas huérfanas; el
|
|
478
|
+
// scope puede no existir ya para el árbol (D8), así que no se filtra por
|
|
479
|
+
// visibilidad — quitar nunca concede.
|
|
480
|
+
const named = rolesToRevoke(await this.catalog.view(), role, scope);
|
|
481
|
+
const target = await this.canonicalOrSelf(scope, 'revoke');
|
|
482
|
+
await this.sql('revoke', () => whereScopeIn(db
|
|
148
483
|
.from('authz_assignments')
|
|
149
484
|
.where('holder_type', subject.type)
|
|
150
485
|
.where('holder_uuid', subject.uuid)
|
|
151
|
-
.
|
|
486
|
+
.whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', [target], 'write').delete());
|
|
152
487
|
}
|
|
153
488
|
async hasRole(subject, role, scope) {
|
|
154
|
-
|
|
155
|
-
const
|
|
489
|
+
assertIdentity({ subject, role, scope });
|
|
490
|
+
const catalog = await this.catalog.view();
|
|
491
|
+
const chain = await this.chain(scope, 'hasRole');
|
|
492
|
+
if (!chain)
|
|
493
|
+
return false;
|
|
494
|
+
// Identidad por uuid (3A · A2): en cada nivel de la cadena cuenta SOLO el
|
|
495
|
+
// rol que el catálogo declara con ese slug para el tipo de ESE nivel
|
|
496
|
+
// (L0.6) —con `{ slug, scopeType }` se recortan los niveles a ese tipo—
|
|
497
|
+
// y la asignación se busca por `(scope, role_uuid)`, no por el slug de
|
|
498
|
+
// un join: dos roles con el mismo slug y owner distinto (3B) no se
|
|
499
|
+
// confunden. Sin rol declarado en ningún nivel no hay consulta que hacer.
|
|
500
|
+
const targets = hasRoleTargets(catalog, role, chain);
|
|
501
|
+
if (targets.length === 0)
|
|
502
|
+
return false;
|
|
503
|
+
const query = db
|
|
156
504
|
.from('authz_assignments as a')
|
|
157
|
-
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
158
|
-
.where('r.slug', role)
|
|
159
505
|
.where('a.holder_type', subject.type)
|
|
160
|
-
.where('a.holder_uuid', subject.uuid)
|
|
506
|
+
.where('a.holder_uuid', subject.uuid)
|
|
507
|
+
.where((outer) => {
|
|
508
|
+
for (const { scope: s, roleUuid } of targets) {
|
|
509
|
+
outer.orWhere((inner) => {
|
|
510
|
+
inner.where('a.scope_type', s.type).where('a.scope_uuid', toDbScopeUuid(s)).where('a.role_uuid', roleUuid);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
const found = await this.first('hasRole', () => this.whereActive(query, 'a.expires_at'));
|
|
161
515
|
return Boolean(found);
|
|
162
516
|
}
|
|
163
517
|
async deny(subject, permission, scope) {
|
|
518
|
+
assertIdentity({ subject, permission, scope });
|
|
164
519
|
const perm = await this.findPermission(permission);
|
|
165
|
-
if (!perm)
|
|
166
|
-
throw new
|
|
167
|
-
|
|
168
|
-
const
|
|
520
|
+
if (!perm)
|
|
521
|
+
throw new UnknownPermissionError(permission);
|
|
522
|
+
const [target] = await this.knownScope(scope, 'deny');
|
|
523
|
+
const findExisting = () => whereScopeIn(db
|
|
169
524
|
.from('authz_denies')
|
|
170
525
|
.where('holder_type', subject.type)
|
|
171
526
|
.where('holder_uuid', subject.uuid)
|
|
172
|
-
.where('permission_uuid', perm.uuid), 'scope', [
|
|
527
|
+
.where('permission_uuid', perm.uuid), 'scope', [target], 'write');
|
|
528
|
+
const existing = await this.first('deny.find', findExisting);
|
|
173
529
|
if (existing)
|
|
174
530
|
return;
|
|
175
531
|
try {
|
|
176
|
-
await db.table('authz_denies').insert({
|
|
532
|
+
await this.sql('deny.insert', () => db.table('authz_denies').insert({
|
|
177
533
|
uuid: uuidv7(),
|
|
178
534
|
holder_type: subject.type,
|
|
179
535
|
holder_uuid: subject.uuid,
|
|
180
536
|
permission_uuid: perm.uuid,
|
|
181
|
-
scope_type:
|
|
182
|
-
scope_uuid: toDbScopeUuid(
|
|
183
|
-
created_at:
|
|
184
|
-
});
|
|
537
|
+
scope_type: target.type,
|
|
538
|
+
scope_uuid: toDbScopeUuid(target),
|
|
539
|
+
created_at: systemClock(),
|
|
540
|
+
}));
|
|
185
541
|
}
|
|
186
542
|
catch (error) {
|
|
187
543
|
// Carrera: si otro proceso insertó el mismo deny, el unique lo detecta
|
|
188
544
|
// y el resultado deseado ya existe.
|
|
189
|
-
const raced = await this.
|
|
190
|
-
.from('authz_denies')
|
|
191
|
-
.where('holder_type', subject.type)
|
|
192
|
-
.where('holder_uuid', subject.uuid)
|
|
193
|
-
.where('permission_uuid', perm.uuid), 'scope', [scope]).first();
|
|
545
|
+
const raced = await this.first('deny.race', findExisting);
|
|
194
546
|
if (!raced)
|
|
195
547
|
throw error;
|
|
196
548
|
}
|
|
197
549
|
}
|
|
198
550
|
async removeDeny(subject, permission, scope) {
|
|
551
|
+
assertIdentity({ subject, permission, scope });
|
|
199
552
|
const perm = await this.findPermission(permission);
|
|
200
553
|
if (!perm)
|
|
201
|
-
|
|
202
|
-
await this.
|
|
554
|
+
throw new UnknownPermissionError(permission);
|
|
555
|
+
const target = await this.canonicalOrSelf(scope, 'removeDeny');
|
|
556
|
+
await this.sql('removeDeny', () => whereScopeIn(db
|
|
203
557
|
.from('authz_denies')
|
|
204
558
|
.where('holder_type', subject.type)
|
|
205
559
|
.where('holder_uuid', subject.uuid)
|
|
206
|
-
.where('permission_uuid', perm.uuid), 'scope', [
|
|
560
|
+
.where('permission_uuid', perm.uuid), 'scope', [target], 'write').delete());
|
|
207
561
|
}
|
|
208
562
|
async listSubjects(role, scope) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
563
|
+
assertIdentity({ role, scope });
|
|
564
|
+
// Un rol que el catálogo no declara para ese nivel (en ningún owner) no
|
|
565
|
+
// tiene holders (D5): nada que leer, ni árbol ni hechos. Un scope que el
|
|
566
|
+
// árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
|
|
567
|
+
// se lee bajo su identidad canónica, y el rol tiene que existir AHÍ (3B ·
|
|
568
|
+
// B2: global o local a un ancestro-o-igual); se busca por su uuid (3A).
|
|
569
|
+
const catalog = await this.catalog.view();
|
|
570
|
+
const asked = normalizeRoleQuery(role);
|
|
571
|
+
// Atajo D5: nada que leer (ni árbol ni hechos) si el catálogo no declara
|
|
572
|
+
// NINGÚN rol que pueda responder — por uuid, si no existe; por slug, si
|
|
573
|
+
// nadie lo declara para ese nivel.
|
|
574
|
+
if (asked.uuid !== undefined ? catalog.roleByUuid(asked.uuid) === null : catalog.rolesNamed(asked.slug, asked.scopeType ?? scope.type).length === 0) {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
const chain = await this.chain(scope, 'listSubjects');
|
|
578
|
+
if (!chain)
|
|
579
|
+
return [];
|
|
580
|
+
const declared = visibleRoleFor(catalog, asked, scope, chainKeysFrom(chain)[0]);
|
|
581
|
+
if (!declared)
|
|
582
|
+
return [];
|
|
583
|
+
const query = whereScopeIn(db.from('authz_assignments as a').where('a.role_uuid', declared.uuid), 'a.scope', [chain[0]], 'read');
|
|
584
|
+
if (!query)
|
|
585
|
+
return [];
|
|
586
|
+
const rows = await this.sql('listSubjects', () => this.whereActive(query, 'a.expires_at').distinct('a.holder_type', 'a.holder_uuid'));
|
|
214
587
|
return rows.map((row) => ({ type: row.holder_type, uuid: row.holder_uuid }));
|
|
215
588
|
}
|
|
216
589
|
async listRoles(subject, scope) {
|
|
217
|
-
|
|
590
|
+
assertIdentity({ subject, scope });
|
|
591
|
+
// Un scope que el árbol no conoce no existe para el motor (D8): nada.
|
|
592
|
+
const chain = await this.chain(scope, 'listRoles');
|
|
593
|
+
if (!chain)
|
|
594
|
+
return [];
|
|
595
|
+
const query = whereScopeIn(db
|
|
596
|
+
.from('authz_assignments as a')
|
|
597
|
+
.where('a.holder_type', subject.type)
|
|
598
|
+
.where('a.holder_uuid', subject.uuid), 'a.scope', [chain[0]], 'read');
|
|
599
|
+
if (!query)
|
|
600
|
+
return [];
|
|
601
|
+
const rows = await this.sql('listRoles', () => this.whereActive(query, 'a.expires_at').distinct('a.role_uuid'));
|
|
602
|
+
// Del uuid al slug por el memo (3A · A2): un rol retirado del catálogo no
|
|
603
|
+
// es membresía (D5), uno declarado para OTRO nivel tampoco, ni uno local
|
|
604
|
+
// a un scope que ya no está en la cadena (3B · B2: la unit se movió).
|
|
605
|
+
const catalog = await this.catalog.view();
|
|
606
|
+
const keys = chainKeysFrom(chain)[0];
|
|
607
|
+
const slugs = new Set();
|
|
608
|
+
for (const row of rows) {
|
|
609
|
+
const declared = declaredRoleAt(catalog, row.role_uuid, chain[0].type, keys);
|
|
610
|
+
if (declared)
|
|
611
|
+
slugs.add(declared.slug);
|
|
612
|
+
}
|
|
613
|
+
return [...slugs];
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Roles directos vigentes del holder en cada scope de la cadena (2D · G5):
|
|
617
|
+
* UNA consulta con `whereScopeIn(chain)` en vez de un `listRoles` por
|
|
618
|
+
* nivel. El filtro por catálogo (D5) es el memo, por uuid (3A · A2): el
|
|
619
|
+
* rol tiene que existir y estar declarado para el nivel de la asignación.
|
|
620
|
+
* La cadena viene ya resuelta por el manager.
|
|
621
|
+
*/
|
|
622
|
+
async rolesInChain(subject, chain) {
|
|
623
|
+
assertIdentity({ subject });
|
|
624
|
+
for (const scope of chain)
|
|
625
|
+
assertScope(scope);
|
|
626
|
+
const query = whereScopeIn(db
|
|
218
627
|
.from('authz_assignments as a')
|
|
219
|
-
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
220
628
|
.where('a.holder_type', subject.type)
|
|
221
|
-
.where('a.holder_uuid', subject.uuid), 'a.scope',
|
|
222
|
-
|
|
629
|
+
.where('a.holder_uuid', subject.uuid), 'a.scope', chain, 'read');
|
|
630
|
+
if (!query)
|
|
631
|
+
return [];
|
|
632
|
+
const rows = await this.sql('rolesInChain', () => this.whereActive(query, 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
|
|
633
|
+
const catalog = await this.catalog.view();
|
|
634
|
+
const keysFrom = chainKeysFrom(chain);
|
|
635
|
+
const levelOf = new Map(chain.map((s, i) => [dbScopeKey(s), i]));
|
|
636
|
+
const result = [];
|
|
637
|
+
const seen = new Set();
|
|
638
|
+
for (const row of rows) {
|
|
639
|
+
// Visible desde el nivel de la ASIGNACIÓN (3B · B2): owner global o en la cadena desde ahí.
|
|
640
|
+
const level = levelOf.get(rowScopeKey(row));
|
|
641
|
+
if (level === undefined)
|
|
642
|
+
continue;
|
|
643
|
+
const declared = declaredRoleAt(catalog, row.role_uuid, row.scope_type, keysFrom[level]);
|
|
644
|
+
if (!declared)
|
|
645
|
+
continue;
|
|
646
|
+
// Dedupe por IDENTIDAD (3D · M1: el uuid, no el slug): dos homónimos
|
|
647
|
+
// asignados en el mismo scope son dos roles y cuentan los dos.
|
|
648
|
+
const dedupe = `${rowScopeKey(row)}\u001f${declared.uuid}`;
|
|
649
|
+
if (seen.has(dedupe))
|
|
650
|
+
continue;
|
|
651
|
+
seen.add(dedupe);
|
|
652
|
+
result.push({ scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, role: declared });
|
|
653
|
+
}
|
|
654
|
+
return result;
|
|
223
655
|
}
|
|
224
656
|
async listRoleScopes(subject, scopeType) {
|
|
225
|
-
|
|
657
|
+
assertIdentity({ subject, scopeType });
|
|
658
|
+
const rows = await this.sql('listRoleScopes', () => this.whereActive(db
|
|
226
659
|
.from('authz_assignments as a')
|
|
227
660
|
.where('a.holder_type', subject.type)
|
|
228
661
|
.where('a.holder_uuid', subject.uuid)
|
|
229
|
-
.where('a.scope_type', scopeType), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid');
|
|
230
|
-
|
|
662
|
+
.where('a.scope_type', scopeType), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
|
|
663
|
+
// Por scope: los roles asignados; cuenta si el árbol lo conoce (D8, una
|
|
664
|
+
// consulta al resolutor por scope, que el memo por request amortiza) Y
|
|
665
|
+
// alguno de sus roles existe ahí (D5; 3B · B2: declarado para ese nivel y
|
|
666
|
+
// global u owner en la cadena).
|
|
667
|
+
const catalog = await this.catalog.view();
|
|
668
|
+
const byScope = new Map();
|
|
669
|
+
for (const row of rows) {
|
|
670
|
+
const k = rowScopeKey(row);
|
|
671
|
+
if (!byScope.has(k))
|
|
672
|
+
byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
|
|
673
|
+
byScope.get(k).roles.push(row.role_uuid);
|
|
674
|
+
}
|
|
675
|
+
const result = [];
|
|
676
|
+
for (const { scope, roles } of byScope.values()) {
|
|
677
|
+
const chain = await this.chain(scope, 'listRoleScopes');
|
|
678
|
+
if (!chain)
|
|
679
|
+
continue;
|
|
680
|
+
const keys = chainKeysFrom(chain)[0];
|
|
681
|
+
if (roles.some((uuid) => this.visibleAt(catalog, uuid, scope, keys)))
|
|
682
|
+
result.push(scope);
|
|
683
|
+
}
|
|
684
|
+
return result;
|
|
685
|
+
}
|
|
686
|
+
/** ¿El rol (por uuid) existe en el scope cuya cadena tiene esas claves? (3B · B2, `declaredRoleAt`) */
|
|
687
|
+
visibleAt(catalog, roleUuid, scope, chainKeys) {
|
|
688
|
+
return declaredRoleAt(catalog, roleUuid, scope.type, chainKeys) !== null;
|
|
231
689
|
}
|
|
232
690
|
async listScopes(subject, permission) {
|
|
691
|
+
assertIdentity({ subject, permission });
|
|
233
692
|
const perm = await this.findPermission(permission);
|
|
234
693
|
if (!perm)
|
|
235
694
|
return [];
|
|
236
|
-
const rows = await this.whereActive(db
|
|
695
|
+
const rows = await this.sql('listScopes.assignments', () => this.whereActive(db
|
|
237
696
|
.from('authz_assignments as a')
|
|
238
697
|
.join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
|
|
239
698
|
.where('rp.permission_uuid', perm.uuid)
|
|
240
699
|
.where('a.holder_type', subject.type)
|
|
241
|
-
.where('a.holder_uuid', subject.uuid), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid');
|
|
242
|
-
const denies = await db
|
|
700
|
+
.where('a.holder_uuid', subject.uuid), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
|
|
701
|
+
const denies = await this.sql('listScopes.denies', () => db
|
|
243
702
|
.from('authz_denies')
|
|
244
703
|
.where('holder_type', subject.type)
|
|
245
704
|
.where('holder_uuid', subject.uuid)
|
|
246
705
|
.where('permission_uuid', perm.uuid)
|
|
247
|
-
.select('scope_type', 'scope_uuid');
|
|
706
|
+
.select('scope_type', 'scope_uuid'));
|
|
248
707
|
const deniedKeys = new Set(denies.map((d) => `${d.scope_type}:${fromDbScopeUuid(d.scope_uuid) ?? ''}`));
|
|
249
|
-
|
|
708
|
+
// Por scope: el conjunto de roles que conceden; el scope se lista si el
|
|
709
|
+
// árbol lo conoce, alguno de esos roles existe ahí (3B · B2) y ningún
|
|
710
|
+
// deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
|
|
711
|
+
const catalog = await this.catalog.view();
|
|
712
|
+
const byScope = new Map();
|
|
250
713
|
for (const row of rows) {
|
|
251
|
-
const
|
|
252
|
-
|
|
714
|
+
const k = rowScopeKey(row);
|
|
715
|
+
if (!byScope.has(k))
|
|
716
|
+
byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
|
|
717
|
+
byScope.get(k).roles.push(row.role_uuid);
|
|
718
|
+
}
|
|
719
|
+
const result = [];
|
|
720
|
+
for (const { scope: candidate, roles } of byScope.values()) {
|
|
721
|
+
// Un scope que el árbol ya no conoce no concede (authorize daría false):
|
|
722
|
+
// no se lista, igual que uno denegado.
|
|
723
|
+
const chain = await this.chain(candidate, 'listScopes');
|
|
724
|
+
if (!chain)
|
|
725
|
+
continue;
|
|
726
|
+
const keys = chainKeysFrom(chain)[0];
|
|
727
|
+
if (!roles.some((uuid) => this.visibleAt(catalog, uuid, candidate, keys)))
|
|
728
|
+
continue;
|
|
253
729
|
const blocked = chain.some((s) => deniedKeys.has(`${s.type}:${s.uuid ?? ''}`));
|
|
254
730
|
if (!blocked)
|
|
255
731
|
result.push(candidate);
|
|
256
732
|
}
|
|
257
733
|
return result;
|
|
258
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Denies directos del holder (2.1, B5): del scope exacto o todos. Solo los
|
|
737
|
+
* de permisos que el catálogo declara (memo); los de scopes que el árbol
|
|
738
|
+
* no conoce no se listan (D8), como en `listRoleScopes`.
|
|
739
|
+
*/
|
|
740
|
+
async listDenies(subject, scope) {
|
|
741
|
+
assertIdentity(scope ? { subject, scope } : { subject });
|
|
742
|
+
const chain = scope ? await this.chain(scope, 'listDenies') : null;
|
|
743
|
+
if (scope && !chain)
|
|
744
|
+
return [];
|
|
745
|
+
const base = db
|
|
746
|
+
.from('authz_denies')
|
|
747
|
+
.where('holder_type', subject.type)
|
|
748
|
+
.where('holder_uuid', subject.uuid);
|
|
749
|
+
const query = chain ? whereScopeIn(base, 'scope', [chain[0]], 'read') : base;
|
|
750
|
+
if (!query)
|
|
751
|
+
return [];
|
|
752
|
+
const rows = await this.sql('listDenies', () => query.select('permission_uuid', 'scope_type', 'scope_uuid'));
|
|
753
|
+
const view = await this.catalog.view();
|
|
754
|
+
const result = [];
|
|
755
|
+
for (const row of rows) {
|
|
756
|
+
const permission = view.permissionSlug(row.permission_uuid);
|
|
757
|
+
if (!permission)
|
|
758
|
+
continue;
|
|
759
|
+
const denyScope = { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) };
|
|
760
|
+
if (!scope && !(await this.chain(denyScope, 'listDenies')))
|
|
761
|
+
continue;
|
|
762
|
+
result.push({ permission, scope: denyScope });
|
|
763
|
+
}
|
|
764
|
+
return result;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Borra asignaciones y denies del scope exacto, en una transacción. Si el
|
|
768
|
+
* árbol aún lo conoce se purga su identidad canónica (K1); si ya no (el
|
|
769
|
+
* consumidor borró la fila antes de avisar) se purga tal cual llegó. Con
|
|
770
|
+
* `DELETE` en SQL la propia sentencia demuestra el cero: no hay residuo
|
|
771
|
+
* posible.
|
|
772
|
+
*/
|
|
773
|
+
async purgeScope(purged) {
|
|
774
|
+
assertScope(purged);
|
|
775
|
+
if (purged.type === APP_SCOPE_TYPE) {
|
|
776
|
+
throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
|
|
777
|
+
}
|
|
778
|
+
const scope = await this.canonicalOrSelf(purged, 'purgeScope');
|
|
779
|
+
// La transacción no es un builder: `guardSql` no puede fijarle el
|
|
780
|
+
// deadline, así que cada DELETE pasa por él por separado (D4). El guard
|
|
781
|
+
// exterior clasifica lo que falle al abrir o confirmar la transacción.
|
|
782
|
+
await this.sql('purgeScope', () => db.transaction(async (trx) => {
|
|
783
|
+
await this.sql('purgeScope.assignments', () => trx
|
|
784
|
+
.from('authz_assignments')
|
|
785
|
+
.where('scope_type', scope.type)
|
|
786
|
+
.where('scope_uuid', toDbScopeUuid(scope))
|
|
787
|
+
.delete());
|
|
788
|
+
await this.sql('purgeScope.denies', () => trx
|
|
789
|
+
.from('authz_denies')
|
|
790
|
+
.where('scope_type', scope.type)
|
|
791
|
+
.where('scope_uuid', toDbScopeUuid(scope))
|
|
792
|
+
.delete());
|
|
793
|
+
}));
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Purga un rol con sus hechos (3B · B4): sus asignaciones en TODOS los
|
|
797
|
+
* scopes, sus vínculos y la fila, en UNA transacción que sube la versión
|
|
798
|
+
* compartida del catálogo como última sentencia (`withAuthzCatalogWrite`):
|
|
799
|
+
* los demás procesos dejan de verlo en su siguiente pregunta. Se lee la
|
|
800
|
+
* fila en fresco (no el memo): purgar es escribir. Con `DELETE` la propia
|
|
801
|
+
* sentencia demuestra el cero. Global o local: la barrera «los globales son
|
|
802
|
+
* inmutables» es del manager (`deleteScopedRole`); por aquí (plataforma,
|
|
803
|
+
* `manager.driver()`) se purga lo que se pida.
|
|
804
|
+
*/
|
|
805
|
+
async purgeRole(roleUuid) {
|
|
806
|
+
assertCatalogUuid('rol', roleUuid);
|
|
807
|
+
const existing = await this.first('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
|
|
808
|
+
if (!existing)
|
|
809
|
+
throw new UnknownRoleError(roleUuid);
|
|
810
|
+
try {
|
|
811
|
+
await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
|
|
812
|
+
await this.sql('purgeRole.assignments', () => trx.from('authz_assignments').where('role_uuid', roleUuid).delete());
|
|
813
|
+
await this.sql('purgeRole.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).delete());
|
|
814
|
+
const deleted = await this.sql('purgeRole.role.delete', () => trx.from('authz_roles').where('uuid', roleUuid).delete());
|
|
815
|
+
if (Number(Array.isArray(deleted) ? deleted[0] : deleted) === 0)
|
|
816
|
+
throw new UnknownRoleError(roleUuid);
|
|
817
|
+
}, { driver: 'database', timeoutMs: this.timeoutMs }));
|
|
818
|
+
}
|
|
819
|
+
finally {
|
|
820
|
+
// Como el sync (2A): este proceso lo ve al instante también con `everyMs`.
|
|
821
|
+
this.catalog.invalidate();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
259
824
|
}
|
|
260
825
|
//# sourceMappingURL=database_driver.js.map
|