@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.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/README.md +462 -35
- package/build/commands/authz_catalog_diff.js +1 -1
- package/build/commands/authz_catalog_diff.js.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
- package/build/commands/authz_catalog_prune_orphans.js +136 -0
- package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
- package/build/commands/authz_catalog_sync.d.ts +17 -0
- package/build/commands/authz_catalog_sync.d.ts.map +1 -1
- package/build/commands/authz_catalog_sync.js +27 -4
- package/build/commands/authz_catalog_sync.js.map +1 -1
- package/build/commands/authz_freeze.d.ts +44 -0
- package/build/commands/authz_freeze.d.ts.map +1 -0
- package/build/commands/authz_freeze.js +95 -0
- package/build/commands/authz_freeze.js.map +1 -0
- package/build/commands/authz_reconcile.d.ts +102 -0
- package/build/commands/authz_reconcile.d.ts.map +1 -0
- package/build/commands/authz_reconcile.js +294 -0
- package/build/commands/authz_reconcile.js.map +1 -0
- package/build/commands/authz_relations_reconcile.d.ts +73 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
- package/build/commands/authz_relations_reconcile.js +225 -0
- package/build/commands/authz_relations_reconcile.js.map +1 -0
- package/build/commands/authz_scopes_relay.d.ts +47 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -0
- package/build/commands/authz_scopes_relay.js +141 -0
- package/build/commands/authz_scopes_relay.js.map +1 -0
- package/build/commands/authz_unfreeze.d.ts +37 -0
- package/build/commands/authz_unfreeze.d.ts.map +1 -0
- package/build/commands/authz_unfreeze.js +92 -0
- package/build/commands/authz_unfreeze.js.map +1 -0
- package/build/commands/main.d.ts +6 -1
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +6 -1
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +46 -4
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +90 -7
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/configure.d.ts +11 -0
- package/build/configure.d.ts.map +1 -1
- package/build/configure.js +37 -1
- package/build/configure.js.map +1 -1
- package/build/index.d.ts +39 -10
- package/build/index.d.ts.map +1 -1
- package/build/index.js +35 -6
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +26 -2
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +48 -2
- package/build/providers/authz_provider.js.map +1 -1
- package/build/services/relations.d.ts +14 -0
- package/build/services/relations.d.ts.map +1 -0
- package/build/services/relations.js +17 -0
- package/build/services/relations.js.map +1 -0
- package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
- package/build/src/catalog/catalog.d.ts.map +1 -0
- package/build/src/{catalog.js → catalog/catalog.js} +120 -14
- package/build/src/catalog/catalog.js.map +1 -0
- package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
- package/build/src/catalog/catalog_cache.d.ts.map +1 -0
- package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
- package/build/src/catalog/catalog_cache.js.map +1 -0
- package/build/src/define_config.d.ts +101 -3
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +86 -4
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +425 -11
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +75 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/database_relations_driver.js +450 -0
- package/build/src/drivers/database_relations_driver.js.map +1 -0
- package/build/src/drivers/openfga_driver.d.ts +713 -119
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2048 -476
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +369 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -0
- package/build/src/drivers/openfga_facts.js +813 -0
- package/build/src/drivers/openfga_facts.js.map +1 -0
- package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/openfga_relations_driver.js +466 -0
- package/build/src/drivers/openfga_relations_driver.js.map +1 -0
- package/build/src/errors.d.ts +258 -5
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +238 -7
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +120 -0
- package/build/src/freeze.d.ts.map +1 -0
- package/build/src/freeze.js +172 -0
- package/build/src/freeze.js.map +1 -0
- package/build/src/http/app_access_middleware.d.ts.map +1 -0
- package/build/src/http/app_access_middleware.js.map +1 -0
- package/build/src/http/resource_access_middleware.d.ts +105 -0
- package/build/src/http/resource_access_middleware.d.ts.map +1 -0
- package/build/src/http/resource_access_middleware.js +81 -0
- package/build/src/http/resource_access_middleware.js.map +1 -0
- package/build/src/identity.d.ts +74 -1
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +100 -2
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +315 -4
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1187 -181
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +6 -6
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +6 -6
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +6 -6
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +6 -6
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +6 -6
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/openfga.d.ts +18 -2
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +15 -1
- package/build/src/openfga.js.map +1 -1
- package/build/src/reconcile.d.ts +37 -0
- package/build/src/reconcile.d.ts.map +1 -0
- package/build/src/reconcile.js +69 -0
- package/build/src/reconcile.js.map +1 -0
- package/build/src/relation_partition_trigger.d.ts +8 -0
- package/build/src/relation_partition_trigger.d.ts.map +1 -0
- package/build/src/relation_partition_trigger.js +85 -0
- package/build/src/relation_partition_trigger.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +58 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -0
- package/build/src/relations/define_relations_config.js +144 -0
- package/build/src/relations/define_relations_config.js.map +1 -0
- package/build/src/relations/manager.d.ts +38 -0
- package/build/src/relations/manager.d.ts.map +1 -0
- package/build/src/relations/manager.js +156 -0
- package/build/src/relations/manager.js.map +1 -0
- package/build/src/relations/reconcile.d.ts +62 -0
- package/build/src/relations/reconcile.d.ts.map +1 -0
- package/build/src/relations/reconcile.js +138 -0
- package/build/src/relations/reconcile.js.map +1 -0
- package/build/src/relations_config_store.d.ts +22 -0
- package/build/src/relations_config_store.d.ts.map +1 -0
- package/build/src/relations_config_store.js +74 -0
- package/build/src/relations_config_store.js.map +1 -0
- package/build/src/scope_outbox.d.ts +69 -0
- package/build/src/scope_outbox.d.ts.map +1 -0
- package/build/src/scope_outbox.js +291 -0
- package/build/src/scope_outbox.js.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.d.ts +14 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.js +26 -1
- package/build/src/shared/backend_guard.js.map +1 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -0
- package/build/src/shared/sql_expiry.js.map +1 -0
- package/build/src/sql_descendants.d.ts +47 -1
- package/build/src/sql_descendants.d.ts.map +1 -1
- package/build/src/sql_descendants.js +75 -1
- package/build/src/sql_descendants.js.map +1 -1
- package/build/src/testing/contract.d.ts +74 -0
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +672 -169
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +6 -0
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +3 -0
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +284 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -0
- package/build/src/testing/migration_contract.js +586 -0
- package/build/src/testing/migration_contract.js.map +1 -0
- package/build/src/testing/relations_contract.d.ts +51 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -0
- package/build/src/testing/relations_contract.js +654 -0
- package/build/src/testing/relations_contract.js.map +1 -0
- package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
- package/build/src/testing/relations_reconcile_contract.js +172 -0
- package/build/src/testing/relations_reconcile_contract.js.map +1 -0
- package/build/src/traits/authz_scopes.js +1 -1
- package/build/src/traits/authz_scopes.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +7 -7
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +865 -82
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +10 -0
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/authorization.stub +104 -4
- package/build/stubs/migration.stub +126 -0
- package/build/stubs/scopes_outbox_migration.stub +57 -0
- package/package.json +4 -2
- package/build/commands/openfga_import.d.ts +0 -34
- package/build/commands/openfga_import.d.ts.map +0 -1
- package/build/commands/openfga_import.js +0 -97
- package/build/commands/openfga_import.js.map +0 -1
- package/build/src/catalog.d.ts.map +0 -1
- package/build/src/catalog.js.map +0 -1
- package/build/src/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog_cache.js.map +0 -1
- package/build/src/drivers/backend_guard.d.ts.map +0 -1
- package/build/src/drivers/backend_guard.js.map +0 -1
- package/build/src/drivers/sql_expiry.d.ts.map +0 -1
- package/build/src/drivers/sql_expiry.js.map +0 -1
- package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
- package/build/src/middleware/app_access_middleware.js.map +0 -1
- /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
- /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
- /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
- /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { InvalidIdentityError, InvalidSlugError, ModelTooLargeError, AuthorizationConfigError, RelationConfigError, } from '../errors.js';
|
|
2
|
+
import { slugAsRelation, RESERVED_SLUG_PREFIXES, RESERVED_FACTS_TYPES } from '../identity.js';
|
|
3
|
+
import { APP_SCOPE_TYPE } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Modo `facts` — el MODELO (c2) y la proyección del catálogo, sin `@openfga/sdk`.
|
|
6
|
+
*
|
|
7
|
+
* Aquí no hay cliente ni decisiones: es la pieza pura que traduce
|
|
8
|
+
* `holderTypes` + los permisos del catálogo al authorization model que el
|
|
9
|
+
* store publica, y los vínculos rol→permiso a las tuplas que lo materializan.
|
|
10
|
+
* Vive aparte del driver para poder juzgarse sin servidor y sin SDK.
|
|
11
|
+
*
|
|
12
|
+
* El modelo es el que fijó el panel 2 (cruce 1, variante **(c2)** del
|
|
13
|
+
* analista, 53/53 invariantes medidos contra OpenFGA v1.19), con la relación
|
|
14
|
+
* `rooted` que le añadió el diseño **(c2r)** (`fase-3b-diseno-r1.md`, medido
|
|
15
|
+
* contra el `:8101`). No se rediseña:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* type user / admin / integration # los holderTypes del consumidor
|
|
19
|
+
* type role # id = roleUuid
|
|
20
|
+
* define permits_<P>: [user:*, admin:*, integration:*]
|
|
21
|
+
* type role_binding # id = <scopeKey>|<roleUuid>
|
|
22
|
+
* define role: [role]
|
|
23
|
+
* define assignee: [<holders> with not_expired]
|
|
24
|
+
* define <P>: assignee and permits_<P> from role
|
|
25
|
+
* type scope # id = 'app' | '<tipo>|<uuid>'
|
|
26
|
+
* define parent: [scope]
|
|
27
|
+
* define binding: [role_binding]
|
|
28
|
+
* define <P>: <P> from binding or <P> from parent
|
|
29
|
+
* define denied_<P>: [<holders>] or denied_<P> from parent
|
|
30
|
+
* define rooted: [user:*, admin:*, integration:*] or rooted from parent
|
|
31
|
+
* define can_<P>: (<P> but not denied_<P>) and rooted
|
|
32
|
+
* define ancestor: parent or ancestor from parent # isWithin/descendantsOf nativos, 0 tuplas
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Por qué (c2) y no (c1) —catálogo por scope— (cruce 1): un cambio de
|
|
36
|
+
* catálogo son 3 deletes en UN `Write` atómico en vez de O(scopes) requests
|
|
37
|
+
* no atómicos, `attached` escribe 1 tupla en vez de 1+K, y el `verify` del
|
|
38
|
+
* catálogo es O(roles×permisos×holders) en vez de O(scopes×…). Se paga con
|
|
39
|
+
* ~0,8 ms por `authorize` y con un techo de permisos más bajo (**≈450 con
|
|
40
|
+
* slugs realistas**; ver `FACTS_MODEL_MAX_BYTES` para de qué depende esa
|
|
41
|
+
* cifra: no es una propiedad del modelo).
|
|
42
|
+
*
|
|
43
|
+
* **Por qué `rooted`** (3b-2i, cierre del 🔴 1 del auditor R2): sin ella, un
|
|
44
|
+
* scope cuya cadena hasta `app` se rompe —`scopes.detached` de un nodo
|
|
45
|
+
* INTERMEDIO, o un nodo que el consumidor nunca notificó— seguía concediendo
|
|
46
|
+
* lo que tuviera colgado y dejaba de heredar los denies de arriba. O sea que
|
|
47
|
+
* `detached` de un nodo propio funcionaba como un `removeDeny` masivo del
|
|
48
|
+
* subárbol, con las barreras de `within` intactas (medido: `removeDeny` 422,
|
|
49
|
+
* `grant` 422, `detached` OK y después `authorize` = `true` con el deny
|
|
50
|
+
* todavía escrito). `rooted` es la ALCANZABILIDAD DE LA RAÍZ materializada
|
|
51
|
+
* por el propio modelo: solo `scope:app` la tiene directa (el *marcador de
|
|
52
|
+
* raíz*, `factsRootTuples`) y todo lo demás la hereda por `parent`, igual que
|
|
53
|
+
* `denied_<P>`. Al volver `can_<P>` una intersección con ella, un subárbol
|
|
54
|
+
* desgajado deja de conceder **sin enumerar nada y sin una sola tupla por
|
|
55
|
+
* scope**. Medido: `authorize` sigue siendo UN solo `Check`, el techo de
|
|
56
|
+
* profundidad no se mueve (22) y el de tamaño baja un ~4 % (con el catálogo
|
|
57
|
+
* de referencia —3 holder types `user`/`admin`/`integration` y slugs
|
|
58
|
+
* `p0`…`pN`— de 721 a 691 permisos).
|
|
59
|
+
*/
|
|
60
|
+
/** Nombre de tipo admitido por FGA (`^[^:#@\s]{1,254}$`). */
|
|
61
|
+
const FGA_TYPE_FORMAT = /^[^:#@\s]{1,254}$/;
|
|
62
|
+
/**
|
|
63
|
+
* `holderTypes` tiene que ser INYECTIVO. Si dos morph names caen en el mismo
|
|
64
|
+
* tipo FGA, para el store son un solo holder: un grant a `users:U` autoriza a
|
|
65
|
+
* `integrations:U`, `listSubjects` devuelve el morph equivocado y un revoke
|
|
66
|
+
* borra al otro (invariante 4, L0.2). El generador del modelo lo "sabía"
|
|
67
|
+
* (deduplicaba con un Set) y publicaba sin quejarse: ahora lanza aquí, al
|
|
68
|
+
* construir el driver y al generar cualquiera de los dos modelos, antes de
|
|
69
|
+
* tocar nada.
|
|
70
|
+
*/
|
|
71
|
+
export function assertHolderTypes(holderTypes) {
|
|
72
|
+
if (!holderTypes || typeof holderTypes !== 'object' || Object.keys(holderTypes).length === 0) {
|
|
73
|
+
throw new AuthorizationConfigError('holderTypes vacío: el driver openfga necesita al menos un holder (morph name → tipo FGA)');
|
|
74
|
+
}
|
|
75
|
+
const morphsByFgaType = new Map();
|
|
76
|
+
for (const [morph, fgaType] of Object.entries(holderTypes)) {
|
|
77
|
+
if (typeof fgaType !== 'string' || !FGA_TYPE_FORMAT.test(fgaType)) {
|
|
78
|
+
throw new AuthorizationConfigError(`holderTypes['${morph}'] = ${JSON.stringify(fgaType)} no es un tipo FGA válido ` +
|
|
79
|
+
`(1-254 caracteres, sin ':', '#', '@' ni espacios)`);
|
|
80
|
+
}
|
|
81
|
+
morphsByFgaType.set(fgaType, [...(morphsByFgaType.get(fgaType) ?? []), morph]);
|
|
82
|
+
}
|
|
83
|
+
const collisions = [...morphsByFgaType.entries()].filter(([, morphs]) => morphs.length > 1);
|
|
84
|
+
if (collisions.length) {
|
|
85
|
+
throw new AuthorizationConfigError(`holderTypes no es inyectivo: ` +
|
|
86
|
+
collisions.map(([fga, morphs]) => `${morphs.join(' y ')} → '${fga}'`).join('; ') +
|
|
87
|
+
`. Dos holders con el mismo tipo FGA serían uno solo para el store.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/* ── Cotas del servidor (cruce 9 del panel) ─────────────────────────────── */
|
|
91
|
+
/**
|
|
92
|
+
* Longitud máxima de un nombre de relación en FGA. `MAX_SLUG_LENGTH` (42) es
|
|
93
|
+
* justo esto menos el prefijo derivado más largo (`permits_`), así que un
|
|
94
|
+
* slug legal siempre cabe: la comprobación de aquí es la red del generador,
|
|
95
|
+
* que también lo llaman herramientas con listas de permisos que no pasaron
|
|
96
|
+
* por `assertValidSlug` (una base sincronizada por otra versión, un driver
|
|
97
|
+
* de terceros).
|
|
98
|
+
*/
|
|
99
|
+
export const FGA_MAX_RELATION_NAME = 50;
|
|
100
|
+
/**
|
|
101
|
+
* Longitud máxima del id de un objeto FGA. Con la gramática publicada nada
|
|
102
|
+
* se acerca (`role_binding:<tipo>|<uuid>|<roleUuid>` son 107 como mucho),
|
|
103
|
+
* pero el id se compone de partes que vienen de la BASE: un catálogo
|
|
104
|
+
* corrupto no puede fabricar un objeto que el store no pueda leer de vuelta.
|
|
105
|
+
*/
|
|
106
|
+
export const FGA_MAX_OBJECT_ID = 256;
|
|
107
|
+
/**
|
|
108
|
+
* Techo del authorization model (default de
|
|
109
|
+
* `OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES`). **El techo son BYTES; el
|
|
110
|
+
* número de permisos que caben NO es una propiedad del modelo** (3b-4 · C3):
|
|
111
|
+
* depende de cuántos holder types declaras, de cuánto miden sus NOMBRES en el
|
|
112
|
+
* modelo y de cuánto miden los slugs de tus permisos. Medido con
|
|
113
|
+
* `factsModelBytes` —que cuenta los mismos bytes que el servidor, contrastado
|
|
114
|
+
* contra un `:8101` real— y fijado por un caso (`3b-4 · C3`):
|
|
115
|
+
*
|
|
116
|
+
* | catálogo | permisos que caben |
|
|
117
|
+
* |---|---|
|
|
118
|
+
* | 1 holder type, slugs `p0`…`pN` | **800** |
|
|
119
|
+
* | 3 holder types (`user`/`admin`/`integration`), slugs `p0`…`pN` | **691** |
|
|
120
|
+
* | 1 holder type, slugs `docs:readN` | **576** |
|
|
121
|
+
* | 3 holder types, slugs `recursoN:accion` (REALISTAS) | **447** |
|
|
122
|
+
* | 3 holder types, slugs de 40 caracteres | **272** |
|
|
123
|
+
*
|
|
124
|
+
* O sea: el «≈691» que se publicó es la cifra de un catálogo cuyos permisos se
|
|
125
|
+
* llaman `p0`, `p1`, `p2`…; **con nombres de permiso normales el techo está en
|
|
126
|
+
* ~450**. Y los mismos tres holder types con nombres más cortos (`bot` en vez
|
|
127
|
+
* de `integration`) dan 721: no basta con decir «tres holder types».
|
|
128
|
+
*
|
|
129
|
+
* Se valida en `syncAuthzCatalog` ANTES de escribir el catálogo: un catálogo
|
|
130
|
+
* que no se puede publicar no se escribe a medias en un entorno y entero en
|
|
131
|
+
* otro. El gate por bytes es exacto y salta antes de escribir, así que
|
|
132
|
+
* equivocarse con la cifra derivada no concede nada: solo sorprende.
|
|
133
|
+
*/
|
|
134
|
+
export const FACTS_MODEL_MAX_BYTES = 262_144;
|
|
135
|
+
/**
|
|
136
|
+
* **Profundidad máxima de cadena que el modelo (c2) resuelve** (3b-2e · E5),
|
|
137
|
+
* MEDIDA contra OpenFGA v1.19 con el `--resolve-node-limit` por defecto (25).
|
|
138
|
+
* Con el grant en la RAÍZ y el `Check` a profundidad creciente, 25
|
|
139
|
+
* repeticiones por punto:
|
|
140
|
+
*
|
|
141
|
+
* | saltos `parent` | `can_<P>` |
|
|
142
|
+
* |---|---|
|
|
143
|
+
* | 21 | 25/25 resuelve |
|
|
144
|
+
* | **22** | **25/25 resuelve** ← la cota que se declara |
|
|
145
|
+
* | 23 | resuelve casi siempre y **falla entre un 4 % y un 26 %** de las veces según la carga — el borde NO es nítido |
|
|
146
|
+
* | 24 | 0/25: siempre falla |
|
|
147
|
+
*
|
|
148
|
+
* (Remedido en 3b-4 · C4 sobre 50 hojas a la misma profundidad: a 22, 200 de
|
|
149
|
+
* 200 resuelven en cuatro lotes; a 23, entre 6 y 13 errores por lote de 50, y
|
|
150
|
+
* 15 de 100 en checks sueltos; a 24, 100 de 100 fallan.)
|
|
151
|
+
*
|
|
152
|
+
* Las otras dos ramas del modelo llegan más lejos (`denied_<P>` hasta 25,
|
|
153
|
+
* `ancestor` hasta 26): manda la más baja, y es `can_<P>` porque es una resta
|
|
154
|
+
* (`difference`) sobre una unión con DOS TTU (`binding` y `parent`), o sea dos
|
|
155
|
+
* reescrituras más que las otras.
|
|
156
|
+
*
|
|
157
|
+
* **Lo importante del hallazgo no es el número, es que el borde es
|
|
158
|
+
* PROBABILÍSTICO**: a 23 saltos la misma pregunta responde casi siempre y
|
|
159
|
+
* falla de vez en cuando (el presupuesto de nodos se consume de forma no
|
|
160
|
+
* determinista al resolver la unión). Por eso se declara **22**, que es la
|
|
161
|
+
* profundidad que resuelve SIEMPRE, y no el primer valor que falló. Un caso
|
|
162
|
+
* de la suite apoyado en 23 habría sido flaky en el artefacto publicado —la
|
|
163
|
+
* misma lección que 3G · Y1—, y de hecho lo fue una vez antes de medirlo.
|
|
164
|
+
*
|
|
165
|
+
* **Y por eso la constante se clava con REPETICIONES, no con una tirada**
|
|
166
|
+
* (3b-4 · C4): el par de casos original —positivo en la cota, negativo en la
|
|
167
|
+
* cota + 2— sujetaba el intervalo [22, 23] y no el 22 (con la constante a 23
|
|
168
|
+
* la suite seguía verde 3 corridas de 3, tester Fase 3b · M12b). El caso que
|
|
169
|
+
* lo cierra hace **500 resoluciones por lado** (10 lotes de 50 con
|
|
170
|
+
* `authorizeMany`): en la cota tienen que resolver las 500, y un salto más
|
|
171
|
+
* tiene que fallar al menos una vez. Con la tasa de fallo más baja jamás
|
|
172
|
+
* medida (4 %) un verde falso es 0,96^500 ≈ 1e-9.
|
|
173
|
+
*
|
|
174
|
+
* Los «~23» que citaba el panel (riesgo S9) eran de un modelo MÁS SIMPLE y
|
|
175
|
+
* estaban sin medir sobre (c2).
|
|
176
|
+
*
|
|
177
|
+
* Pasado el techo el servidor responde 400 («resolution required too many
|
|
178
|
+
* rewrite rules») y el paquete lo propaga como 503, **nunca como un `false`**
|
|
179
|
+
* (invariante 5): es fail-closed, pero es un DoS al alcance de quien pueda
|
|
180
|
+
* crear sub-scopes anidados, y `database` no tiene ese techo — el mismo árbol
|
|
181
|
+
* es legal en un driver y una caída en el otro. Sube
|
|
182
|
+
* `OPENFGA_RESOLVE_NODE_LIMIT` en el servidor si tu árbol es más profundo.
|
|
183
|
+
*/
|
|
184
|
+
export const FACTS_MAX_RESOLVE_DEPTH = 22;
|
|
185
|
+
/** Tope de checks por `batchCheck` en OpenFGA (el driver trocea en lotes de este tamaño). */
|
|
186
|
+
export const FGA_MAX_BATCH_CHECK = 50;
|
|
187
|
+
/** Fracción del techo a partir de la cual se avisa (cruce 9: "aviso al 80 %"). */
|
|
188
|
+
export const FACTS_MODEL_WARN_RATIO = 0.8;
|
|
189
|
+
/** El tipo FGA cuyo id es el uuid del rol del catálogo. */
|
|
190
|
+
export const FACTS_ROLE_TYPE = 'role';
|
|
191
|
+
/** Prefijo de la familia que materializa el catálogo (`role:<uuid>#permits_<P>`). */
|
|
192
|
+
export const FACTS_PERMITS_PREFIX = 'permits_';
|
|
193
|
+
/**
|
|
194
|
+
* **La relación de (c2r)**: «desde este scope se llega a la raíz `app`»
|
|
195
|
+
* (3b-2i). Es una relación PROPIA del modelo, como `parent` o `ancestor`, y
|
|
196
|
+
* la única que se escribe una vez por STORE en vez de por hecho: el marcador
|
|
197
|
+
* de raíz (`factsRootTuples`).
|
|
198
|
+
*/
|
|
199
|
+
export const FACTS_ROOTED_RELATION = 'rooted';
|
|
200
|
+
/* ── Relaciones derivadas de un permiso ─────────────────────────────────── */
|
|
201
|
+
/**
|
|
202
|
+
* Relaciones propias del modelo (no derivadas de ningún permiso). Un permiso
|
|
203
|
+
* que produzca uno de estos nombres invalidaría el modelo entero (S14):
|
|
204
|
+
* `assertValidSlug` ya los reserva en el núcleo para AMBOS drivers, y aquí
|
|
205
|
+
* entran en el mismo `Map` que las cuatro familias para que el generador no
|
|
206
|
+
* dependa de que alguien haya validado antes.
|
|
207
|
+
*/
|
|
208
|
+
const OWN_RELATIONS = Object.freeze([
|
|
209
|
+
['role', "relación propia del modelo (role_binding#role)"],
|
|
210
|
+
['assignee', "relación propia del modelo (role_binding#assignee)"],
|
|
211
|
+
['parent', "relación propia del modelo (scope#parent)"],
|
|
212
|
+
['binding', "relación propia del modelo (scope#binding)"],
|
|
213
|
+
['ancestor', "relación propia del modelo (scope#ancestor)"],
|
|
214
|
+
['rooted', "relación propia del modelo (scope#rooted)"],
|
|
215
|
+
]);
|
|
216
|
+
/** Las cuatro relaciones que un permiso genera, sin validar nada. */
|
|
217
|
+
export function factsRelationsOf(permission) {
|
|
218
|
+
const base = slugAsRelation(permission);
|
|
219
|
+
return {
|
|
220
|
+
permission,
|
|
221
|
+
base,
|
|
222
|
+
can: `can_${base}`,
|
|
223
|
+
denied: `denied_${base}`,
|
|
224
|
+
permits: `${FACTS_PERMITS_PREFIX}${base}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* **S4, bloqueante del juez.** Con CUATRO familias, dos permisos distintos
|
|
229
|
+
* pueden generar el mismo nombre de relación: `can_x` sale del permiso
|
|
230
|
+
* `can_x` y también del permiso `x`. El generador de antes las colapsaba en
|
|
231
|
+
* silencio y el modelo publicado anulaba un deny (el auditor lo reprodujo de
|
|
232
|
+
* punta a punta con `allowed: true`). Se detecta con un `Map` nombre→origen
|
|
233
|
+
* y se LANZA: nunca se escribe un modelo ambiguo.
|
|
234
|
+
*
|
|
235
|
+
* Es un espacio de nombres plano a propósito (aunque las relaciones vivan en
|
|
236
|
+
* tipos distintos): las familias tienen prefijos disjuntos, así que dos
|
|
237
|
+
* permisos legales jamás chocan aquí, y lo que sí choca es exactamente lo que
|
|
238
|
+
* `RESERVED_SLUGS`/`RESERVED_SLUG_PREFIXES` prohíben en el núcleo.
|
|
239
|
+
*/
|
|
240
|
+
export function factsRelationMap(permissions) {
|
|
241
|
+
const origin = new Map(OWN_RELATIONS);
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const permission of permissions) {
|
|
244
|
+
const relations = factsRelationsOf(permission);
|
|
245
|
+
for (const name of [relations.base, relations.can, relations.denied, relations.permits]) {
|
|
246
|
+
// Cota de nombre de relación (A4): se nombra el permiso y el prefijo
|
|
247
|
+
// que lo desborda, que es lo que el operador tiene que acortar.
|
|
248
|
+
if (name.length > FGA_MAX_RELATION_NAME) {
|
|
249
|
+
const prefix = name.slice(0, name.length - relations.base.length);
|
|
250
|
+
throw new InvalidSlugError(`El permiso '${permission}' no es publicable en el modelo facts: su relación ` +
|
|
251
|
+
`'${name}' tiene ${name.length} caracteres y FGA admite ${FGA_MAX_RELATION_NAME} ` +
|
|
252
|
+
`(el prefijo '${prefix || '(ninguno)'}' añade ${name.length - relations.base.length}). Usa un slug más corto.`);
|
|
253
|
+
}
|
|
254
|
+
const other = origin.get(name);
|
|
255
|
+
if (other !== undefined) {
|
|
256
|
+
throw new InvalidSlugError(`Colisión de relaciones en el modelo facts: '${name}' la generan ${other} y el permiso '${permission}'. ` +
|
|
257
|
+
`Las cuatro familias del modelo (<P>, can_<P>, denied_<P>, permits_<P>) comparten espacio de nombres: ` +
|
|
258
|
+
`renombra uno de los dos permisos.`);
|
|
259
|
+
}
|
|
260
|
+
origin.set(name, `el permiso '${permission}'`);
|
|
261
|
+
}
|
|
262
|
+
out.push(relations);
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
/* ── El modelo (c2) ─────────────────────────────────────────────────────── */
|
|
267
|
+
const ttu = (tupleset, relation) => ({
|
|
268
|
+
tupleToUserset: { tupleset: { relation: tupleset }, computedUserset: { relation } },
|
|
269
|
+
});
|
|
270
|
+
const computed = (relation) => ({ computedUserset: { relation } });
|
|
271
|
+
const NO_DIRECT = { directly_related_user_types: [] };
|
|
272
|
+
/**
|
|
273
|
+
* El authorization model del modo `facts` en el JSON del API de FGA. El mismo
|
|
274
|
+
* `holderTypes` y el mismo conjunto de permisos deben usarse al construir el
|
|
275
|
+
* driver: si difieren, los checks no encuentran las tuplas y `permits_<P>` de
|
|
276
|
+
* un permiso que el modelo no declara es un 400 del servidor.
|
|
277
|
+
*
|
|
278
|
+
* `permissions` son los slugs del CATÁLOGO (`authz_permissions`), que sigue
|
|
279
|
+
* siendo propiedad local: esto es una proyección derivada y reconstruible, no
|
|
280
|
+
* una fuente de verdad (regla del catálogo reescrita, cruce 7 del panel).
|
|
281
|
+
*/
|
|
282
|
+
export function openFgaFactsModel(holderTypeMap, permissions, relationsConfig) {
|
|
283
|
+
assertHolderTypes(holderTypeMap);
|
|
284
|
+
const relations = factsRelationMap(permissions);
|
|
285
|
+
const holderTypes = Object.values(holderTypeMap);
|
|
286
|
+
const direct = holderTypes.map((type) => ({ type }));
|
|
287
|
+
const wildcards = holderTypes.map((type) => ({ type, wildcard: {} }));
|
|
288
|
+
const directWithExpiry = [
|
|
289
|
+
...direct,
|
|
290
|
+
...holderTypes.map((type) => ({ type, condition: 'not_expired' })),
|
|
291
|
+
];
|
|
292
|
+
// type role — el catálogo por tuplas: `role:<uuid>#permits_<P>@<holder>:*`.
|
|
293
|
+
// Un vínculo rol→permiso es UNA tupla global, no una por scope (c2).
|
|
294
|
+
const roleRelations = {};
|
|
295
|
+
const roleMetadata = {};
|
|
296
|
+
for (const r of relations) {
|
|
297
|
+
roleRelations[r.permits] = { this: {} };
|
|
298
|
+
roleMetadata[r.permits] = { directly_related_user_types: wildcards };
|
|
299
|
+
}
|
|
300
|
+
// type role_binding — la asignación. `<P>` es la INTERSECCIÓN de "estás
|
|
301
|
+
// asignado aquí" con "tu rol vincula ese permiso": el catálogo se edita en
|
|
302
|
+
// runtime (tuplas) sin tocar los bindings.
|
|
303
|
+
const bindingRelations = { role: { this: {} }, assignee: { this: {} } };
|
|
304
|
+
const bindingMetadata = {
|
|
305
|
+
role: { directly_related_user_types: [{ type: 'role' }] },
|
|
306
|
+
assignee: { directly_related_user_types: directWithExpiry },
|
|
307
|
+
};
|
|
308
|
+
for (const r of relations) {
|
|
309
|
+
bindingRelations[r.base] = {
|
|
310
|
+
intersection: { child: [computed('assignee'), ttu('role', r.permits)] },
|
|
311
|
+
};
|
|
312
|
+
bindingMetadata[r.base] = NO_DIRECT;
|
|
313
|
+
}
|
|
314
|
+
// type scope — el árbol. `<P>` hereda hacia ABAJO por `parent` (invariante
|
|
315
|
+
// 1), `denied_<P>` también, y `can_<P>` es la resta (invariante 2: el deny
|
|
316
|
+
// explícito gana). `ancestor` da `isWithin`/`descendantsOf` sin una sola
|
|
317
|
+
// tupla extra.
|
|
318
|
+
const scopeRelations = { parent: { this: {} }, binding: { this: {} } };
|
|
319
|
+
const scopeMetadata = {
|
|
320
|
+
parent: { directly_related_user_types: [{ type: 'scope' }] },
|
|
321
|
+
binding: { directly_related_user_types: [{ type: 'role_binding' }] },
|
|
322
|
+
};
|
|
323
|
+
for (const r of relations) {
|
|
324
|
+
scopeRelations[r.base] = { union: { child: [ttu('binding', r.base), ttu('parent', r.base)] } };
|
|
325
|
+
scopeRelations[r.denied] = { union: { child: [{ this: {} }, ttu('parent', r.denied)] } };
|
|
326
|
+
// (c2r): lo concedido, menos lo denegado, **y solo si el scope llega a la
|
|
327
|
+
// raíz**. La intersección va aquí y no dentro de `<P>` a propósito:
|
|
328
|
+
// `can_<P>` es exactamente lo que responde `authorize` y nada más, así que
|
|
329
|
+
// «lo que decide» y «lo que se hereda» siguen separados.
|
|
330
|
+
scopeRelations[r.can] = {
|
|
331
|
+
intersection: {
|
|
332
|
+
child: [
|
|
333
|
+
{ difference: { base: computed(r.base), subtract: computed(r.denied) } },
|
|
334
|
+
computed(FACTS_ROOTED_RELATION),
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
scopeMetadata[r.base] = NO_DIRECT;
|
|
339
|
+
scopeMetadata[r.denied] = { directly_related_user_types: direct };
|
|
340
|
+
scopeMetadata[r.can] = NO_DIRECT;
|
|
341
|
+
}
|
|
342
|
+
scopeRelations.ancestor = { union: { child: [computed('parent'), ttu('parent', 'ancestor')] } };
|
|
343
|
+
scopeMetadata.ancestor = NO_DIRECT;
|
|
344
|
+
// El marcador de raíz es lo ÚNICO directo de `rooted`: `scope:app` lo lleva
|
|
345
|
+
// (una tupla por holder type en todo el store) y el resto del árbol la
|
|
346
|
+
// hereda por `parent`, exactamente igual que `denied_<P>`. Es una rama
|
|
347
|
+
// PARALELA al `difference`, y más barata que él, por eso no baja el techo
|
|
348
|
+
// de profundidad (medido: 22 sólido en las cinco variantes del diseño).
|
|
349
|
+
scopeRelations[FACTS_ROOTED_RELATION] = {
|
|
350
|
+
union: { child: [{ this: {} }, ttu('parent', FACTS_ROOTED_RELATION)] },
|
|
351
|
+
};
|
|
352
|
+
scopeMetadata[FACTS_ROOTED_RELATION] = { directly_related_user_types: wildcards };
|
|
353
|
+
const typeDefinitions = [
|
|
354
|
+
...holderTypes.map((type) => ({ type, relations: {}, metadata: null })),
|
|
355
|
+
{ type: 'role', relations: roleRelations, metadata: { relations: roleMetadata } },
|
|
356
|
+
{ type: 'role_binding', relations: bindingRelations, metadata: { relations: bindingMetadata } },
|
|
357
|
+
{ type: 'scope', relations: scopeRelations, metadata: { relations: scopeMetadata } },
|
|
358
|
+
];
|
|
359
|
+
// Fase 4-1 · el modelo FUSIONADO: las relaciones ReBAC (`group` + los tipos
|
|
360
|
+
// de objeto declarados) van al MISMO modelo y el MISMO store que `facts`. El
|
|
361
|
+
// generador valida ANTES de emitir que ningún tipo ni relación de relaciones
|
|
362
|
+
// pisa un tipo o una familia reservados de `facts` (⚪4) ni un permiso del
|
|
363
|
+
// catálogo (F-04): en el store compartido los ids viven en el mismo espacio.
|
|
364
|
+
if (relationsConfig) {
|
|
365
|
+
typeDefinitions.push(...factsRelationTypeDefinitions(relations, relationsConfig, holderTypes));
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
schema_version: '1.1',
|
|
369
|
+
type_definitions: typeDefinitions,
|
|
370
|
+
conditions: {
|
|
371
|
+
not_expired: {
|
|
372
|
+
name: 'not_expired',
|
|
373
|
+
expression: 'current_time < valid_until',
|
|
374
|
+
parameters: {
|
|
375
|
+
current_time: { type_name: 'TYPE_NAME_TIMESTAMP' },
|
|
376
|
+
valid_until: { type_name: 'TYPE_NAME_TIMESTAMP' },
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/* ── Cuánto ocupa el modelo PARA EL SERVIDOR ────────────────────────────── */
|
|
383
|
+
/**
|
|
384
|
+
* OpenFGA no mide el JSON: mide `proto.Size(AuthorizationModel)` y rechaza
|
|
385
|
+
* por encima de `OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES`. Y el JSON no
|
|
386
|
+
* sirve de aproximación: medido contra v1.19, la razón proto/JSON va de 0,33
|
|
387
|
+
* (slugs cortos) a 0,57 (slugs de 40), así que un techo sobre el JSON o deja
|
|
388
|
+
* pasar lo que el servidor rechaza o rechaza catálogos legales por el doble
|
|
389
|
+
* de margen. Se calcula el tamaño protobuf EXACTO, que para este modelo es
|
|
390
|
+
* aritmética de longitudes, y un caso de la suite lo contrasta con el número
|
|
391
|
+
* que el propio servidor reporta al rechazar (delta 0 en cuatro formas de
|
|
392
|
+
* catálogo distintas).
|
|
393
|
+
*
|
|
394
|
+
* Reglas de proto3 que se aplican aquí: campo length-delimited = tag (1 byte,
|
|
395
|
+
* todos los campos del esquema son 1..15) + varint de la longitud + payload;
|
|
396
|
+
* un `string` vacío y un enum 0 no se serializan; un `map<K,V>` es una
|
|
397
|
+
* entrada por par con `key` en el campo 1 y `value` en el 2; un mensaje vacío
|
|
398
|
+
* (`this`, `wildcard`) ocupa tag + longitud 0.
|
|
399
|
+
*/
|
|
400
|
+
const varintSize = (value) => value < 128 ? 1 : value < 16_384 ? 2 : value < 2_097_152 ? 3 : 4;
|
|
401
|
+
/** Un campo length-delimited de `bytes` bytes de payload. */
|
|
402
|
+
const fieldSize = (bytes) => 1 + varintSize(bytes) + bytes;
|
|
403
|
+
/** Un `string` de proto3: el vacío no se serializa. */
|
|
404
|
+
const stringSize = (value) => {
|
|
405
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
406
|
+
return bytes === 0 ? 0 : fieldSize(bytes);
|
|
407
|
+
};
|
|
408
|
+
/** Una entrada de `map<string, M>`: mensaje `{ key = 1, value = 2 }`. */
|
|
409
|
+
const mapEntrySize = (key, value) => fieldSize(stringSize(key) + (value === 0 ? 2 : fieldSize(value)));
|
|
410
|
+
/** `ObjectRelation { object = 1, relation = 2 }`. */
|
|
411
|
+
const objectRelationSize = (relation) => stringSize(relation.object ?? '') + stringSize(relation.relation ?? '');
|
|
412
|
+
/** `Userset`: un `oneof` de seis, cada rama un campo length-delimited. */
|
|
413
|
+
function usersetSize(userset) {
|
|
414
|
+
if (!userset)
|
|
415
|
+
return 0;
|
|
416
|
+
if (userset.this)
|
|
417
|
+
return 2;
|
|
418
|
+
if (userset.computedUserset)
|
|
419
|
+
return fieldSize(objectRelationSize(userset.computedUserset));
|
|
420
|
+
if (userset.tupleToUserset) {
|
|
421
|
+
const { tupleset, computedUserset } = userset.tupleToUserset;
|
|
422
|
+
return fieldSize(fieldSize(objectRelationSize(tupleset)) + fieldSize(objectRelationSize(computedUserset)));
|
|
423
|
+
}
|
|
424
|
+
const children = userset.union ?? userset.intersection;
|
|
425
|
+
if (children) {
|
|
426
|
+
return fieldSize(children.child.reduce((total, child) => total + fieldSize(usersetSize(child)), 0));
|
|
427
|
+
}
|
|
428
|
+
if (userset.difference) {
|
|
429
|
+
return fieldSize(fieldSize(usersetSize(userset.difference.base)) + fieldSize(usersetSize(userset.difference.subtract)));
|
|
430
|
+
}
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
/** `RelationReference { type = 1, relation = 2 | wildcard = 3, condition = 4 }`. */
|
|
434
|
+
function relationReferenceSize(reference) {
|
|
435
|
+
let bytes = stringSize(reference.type);
|
|
436
|
+
if (reference.relation)
|
|
437
|
+
bytes += stringSize(reference.relation);
|
|
438
|
+
if (reference.wildcard)
|
|
439
|
+
bytes += 2;
|
|
440
|
+
if (reference.condition)
|
|
441
|
+
bytes += stringSize(reference.condition);
|
|
442
|
+
return bytes;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Los bytes que el servidor contará para este modelo. Incluye el id (un ULID
|
|
446
|
+
* de 26 caracteres) porque el servidor lo asigna ANTES de medir: sin él la
|
|
447
|
+
* cuenta se queda 28 bytes corta.
|
|
448
|
+
*/
|
|
449
|
+
export function factsModelBytes(model) {
|
|
450
|
+
let total = fieldSize(26) + stringSize(model.schema_version);
|
|
451
|
+
for (const definition of model.type_definitions) {
|
|
452
|
+
let bytes = stringSize(definition.type);
|
|
453
|
+
for (const [name, userset] of Object.entries(definition.relations ?? {})) {
|
|
454
|
+
bytes += mapEntrySize(name, usersetSize(userset));
|
|
455
|
+
}
|
|
456
|
+
if (definition.metadata) {
|
|
457
|
+
let metadata = 0;
|
|
458
|
+
for (const [name, relation] of Object.entries(definition.metadata.relations ?? {})) {
|
|
459
|
+
const types = (relation.directly_related_user_types ?? []).reduce((sum, reference) => sum + fieldSize(relationReferenceSize(reference)), 0);
|
|
460
|
+
metadata += mapEntrySize(name, types);
|
|
461
|
+
}
|
|
462
|
+
bytes += fieldSize(metadata);
|
|
463
|
+
}
|
|
464
|
+
total += fieldSize(bytes);
|
|
465
|
+
}
|
|
466
|
+
for (const [name, condition] of Object.entries(model.conditions ?? {})) {
|
|
467
|
+
let bytes = stringSize(condition.name) + stringSize(condition.expression);
|
|
468
|
+
for (const parameter of Object.keys(condition.parameters ?? {})) {
|
|
469
|
+
// `ConditionParamTypeRef { type_name = 1 }` (un enum ≠ 0: 2 bytes).
|
|
470
|
+
bytes += mapEntrySize(parameter, 2);
|
|
471
|
+
}
|
|
472
|
+
total += mapEntrySize(name, bytes);
|
|
473
|
+
}
|
|
474
|
+
return total;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* ¿Este catálogo se puede PUBLICAR como modelo `facts`? Se responde ANTES de
|
|
478
|
+
* escribir nada (`syncAuthzCatalog`), no en runtime: un catálogo que rebasa
|
|
479
|
+
* el techo del servidor entra en la base y deja el store sin poder
|
|
480
|
+
* regenerarse, que es la avería silenciosa.
|
|
481
|
+
*
|
|
482
|
+
* Pasado el 80 % del techo se AVISA por el canal de log del driver: quien
|
|
483
|
+
* declara permisos a ese ritmo tiene que enterarse antes de chocar, no el día
|
|
484
|
+
* del deploy que no arranca.
|
|
485
|
+
*/
|
|
486
|
+
export function assertFactsModelPublishable(holderTypeMap, permissions, warn, relationsConfig) {
|
|
487
|
+
// El gate mide el modelo FUSIONADO (Fase 4-1): si `relationsConfig` llega,
|
|
488
|
+
// los tipos de relaciones cuentan para el techo. Sin eso, un
|
|
489
|
+
// `defineRelationsConfig` empujaría el modelo por encima de los 262.144 B en
|
|
490
|
+
// SILENCIO —medía solo `facts`— y dejaría el store sin poder regenerarse
|
|
491
|
+
// (el hueco que señaló el auditor).
|
|
492
|
+
const model = openFgaFactsModel(holderTypeMap, permissions, relationsConfig);
|
|
493
|
+
const bytes = factsModelBytes(model);
|
|
494
|
+
if (bytes > FACTS_MODEL_MAX_BYTES) {
|
|
495
|
+
throw new ModelTooLargeError(`El catálogo no cabe en un authorization model de OpenFGA: ${permissions.length} permisos ` +
|
|
496
|
+
`producen ${bytes} bytes y el techo son ${FACTS_MODEL_MAX_BYTES}. ` +
|
|
497
|
+
`Reduce permisos (o sube OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES en el servidor, que es del pliego de infraestructura).`);
|
|
498
|
+
}
|
|
499
|
+
if (warn && bytes >= FACTS_MODEL_MAX_BYTES * FACTS_MODEL_WARN_RATIO) {
|
|
500
|
+
warn(`authz(openfga): el modelo facts va por ${bytes} de ${FACTS_MODEL_MAX_BYTES} bytes ` +
|
|
501
|
+
`(${Math.round((bytes / FACTS_MODEL_MAX_BYTES) * 100)} %, ${permissions.length} permisos). ` +
|
|
502
|
+
`Pasado el techo el catálogo deja de ser publicable.`);
|
|
503
|
+
}
|
|
504
|
+
return { bytes, permissions: permissions.length };
|
|
505
|
+
}
|
|
506
|
+
/** Cota de id de objeto (A4): lo que el store no podría leer de vuelta no se escribe. */
|
|
507
|
+
export function assertFgaObjectId(kind, id) {
|
|
508
|
+
if (id.length > FGA_MAX_OBJECT_ID) {
|
|
509
|
+
throw new InvalidIdentityError(`Id de objeto FGA inválido (${kind}): '${id.slice(0, 60)}…' tiene ${id.length} caracteres ` +
|
|
510
|
+
`y FGA admite ${FGA_MAX_OBJECT_ID}.`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Las tuplas que materializan los vínculos rol→permiso del catálogo. Una por
|
|
515
|
+
* (rol, permiso, holder): el comodín de USUARIO es lo que hace (c2) barato
|
|
516
|
+
* —quitar un permiso de un rol son `holders` deletes en un solo `Write`, no
|
|
517
|
+
* una escritura por scope—.
|
|
518
|
+
*
|
|
519
|
+
* NO es catálogo: es una proyección derivada, reconstruible desde `authz_*`,
|
|
520
|
+
* que ningún camino de lectura del driver consulta para responder qué
|
|
521
|
+
* permisos tiene un rol (cruce 7 del panel; A6).
|
|
522
|
+
*/
|
|
523
|
+
export function factsCatalogTuples(roles, holderTypeMap) {
|
|
524
|
+
assertHolderTypes(holderTypeMap);
|
|
525
|
+
const holderTypes = Object.values(holderTypeMap);
|
|
526
|
+
const tuples = [];
|
|
527
|
+
for (const role of roles) {
|
|
528
|
+
const object = `${FACTS_ROLE_TYPE}:${role.uuid}`;
|
|
529
|
+
assertFgaObjectId(FACTS_ROLE_TYPE, object);
|
|
530
|
+
for (const permission of role.permissions) {
|
|
531
|
+
const { permits } = factsRelationsOf(permission);
|
|
532
|
+
for (const holderType of holderTypes) {
|
|
533
|
+
tuples.push({ user: `${holderType}:*`, relation: permits, object });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return tuples;
|
|
538
|
+
}
|
|
539
|
+
/** Clave textual de una tupla de la proyección, para comparar conjuntos. */
|
|
540
|
+
export function factsTupleId(tuple) {
|
|
541
|
+
return `${tuple.user}#${tuple.relation}@${tuple.object}`;
|
|
542
|
+
}
|
|
543
|
+
/* ── El ÁRBOL como hechos (3b-2b) ───────────────────────────────────────── */
|
|
544
|
+
/** El tipo FGA cuyo id es la `scopeKey` del paquete (`app` o `<tipo>|<uuid>`). */
|
|
545
|
+
export const FACTS_SCOPE_TYPE = 'scope';
|
|
546
|
+
/** La arista del árbol: `scope:<hijo>#parent@scope:<padre>` (una por nodo). */
|
|
547
|
+
export const FACTS_PARENT_RELATION = 'parent';
|
|
548
|
+
/**
|
|
549
|
+
* El objeto FGA de un scope. La `scopeKey` es la MISMA codificación que ya
|
|
550
|
+
* usan los ids de binding (`identity.ts`), así que el árbol y los hechos
|
|
551
|
+
* hablan del mismo nodo con la misma cadena; el scope tiene que venir ya
|
|
552
|
+
* CANÓNICO (invariante 17: `chain[0]`), o se abriría una segunda rama para
|
|
553
|
+
* el alias del uuid.
|
|
554
|
+
*/
|
|
555
|
+
export function factsScopeObject(key) {
|
|
556
|
+
const object = `${FACTS_SCOPE_TYPE}:${key}`;
|
|
557
|
+
assertFgaObjectId(FACTS_SCOPE_TYPE, object);
|
|
558
|
+
return object;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* La arista `hijo → padre` del árbol. Una sola tupla por nodo (c2): mover un
|
|
562
|
+
* subárbol es reescribir ESA tupla, no recorrer nada — por eso el `moved`
|
|
563
|
+
* del cruce 8 cabe en un `Write` atómico.
|
|
564
|
+
*/
|
|
565
|
+
export function factsParentTuple(childKey, parentKey) {
|
|
566
|
+
return {
|
|
567
|
+
user: factsScopeObject(parentKey),
|
|
568
|
+
relation: FACTS_PARENT_RELATION,
|
|
569
|
+
object: factsScopeObject(childKey),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* **El marcador de raíz** (3b-2i): `scope:app#rooted@<holder>:*`, una tupla
|
|
574
|
+
* por holder type en TODO el store — cero por scope.
|
|
575
|
+
*
|
|
576
|
+
* Es lo que hace verdadera la premisa de `rooted`: solo la raíz la tiene
|
|
577
|
+
* directa. `attached`/`moved`/`detached` no escriben ni una tupla más por su
|
|
578
|
+
* culpa (la outbox y el relay no llevan entradas nuevas), y a cambio lo
|
|
579
|
+
* escribe quien toca el CATÁLOGO —`projectCatalog`, en cada
|
|
580
|
+
* `syncAuthzCatalog` con proyección, de forma idempotente— porque ése es el
|
|
581
|
+
* momento en el que un holderType nuevo del config aparece.
|
|
582
|
+
*
|
|
583
|
+
* ⚠️ **Modo de fallo que hay que conocer: sin marcador, todo el store
|
|
584
|
+
* DENIEGA** (medido: `can_<P>` es `false` en `app`, en la org y en la unit).
|
|
585
|
+
* Es fail-closed —no es una fuga— y ruidoso a la primera pregunta, pero es
|
|
586
|
+
* una caída total silenciosa desde el punto de vista del log. Por eso el
|
|
587
|
+
* marcador se repone en cada sync y `authz:reconcile` (3b-3) tiene que
|
|
588
|
+
* reportarlo como deriva cuando falte.
|
|
589
|
+
*/
|
|
590
|
+
export function factsRootTuples(holderTypeMap) {
|
|
591
|
+
assertHolderTypes(holderTypeMap);
|
|
592
|
+
return Object.values(holderTypeMap).map((type) => ({
|
|
593
|
+
user: `${type}:*`,
|
|
594
|
+
relation: FACTS_ROOTED_RELATION,
|
|
595
|
+
object: factsScopeObject(APP_SCOPE_TYPE),
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
/* ── Los HECHOS del modo `facts` (3b-2c) ────────────────────────────────── */
|
|
599
|
+
/** El tipo FGA de una asignación: `role_binding:<scopeKey>|<roleUuid>`. */
|
|
600
|
+
export const FACTS_BINDING_TYPE = 'role_binding';
|
|
601
|
+
/** `scope:<key>#binding@role_binding:…` — qué asignaciones cuelgan del scope. */
|
|
602
|
+
export const FACTS_BINDING_RELATION = 'binding';
|
|
603
|
+
/** `role_binding:…#role@role:<roleUuid>` — qué rol vincula la asignación. */
|
|
604
|
+
export const FACTS_ROLE_RELATION = 'role';
|
|
605
|
+
/** `role_binding:…#assignee@<holder>` — quién está asignado (con la caducidad). */
|
|
606
|
+
export const FACTS_ASSIGNEE_RELATION = 'assignee';
|
|
607
|
+
/**
|
|
608
|
+
* El objeto de una asignación. Mismo id que en el modo `resolver`
|
|
609
|
+
* (`<scopeKey>|<roleUuid>`, 3A · A1: uuid del catálogo, nunca el slug), y por
|
|
610
|
+
* eso el cambio de modelo no renombra un solo binding: lo que (c2) añade son
|
|
611
|
+
* las DOS aristas de abajo, no una identidad nueva.
|
|
612
|
+
*/
|
|
613
|
+
export function factsBindingObject(scopeKeyValue, roleUuid) {
|
|
614
|
+
const object = `${FACTS_BINDING_TYPE}:${scopeKeyValue}|${roleUuid}`;
|
|
615
|
+
assertFgaObjectId(FACTS_BINDING_TYPE, object);
|
|
616
|
+
return object;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Las dos aristas que hacen ALCANZABLE una asignación en (c2): el binding
|
|
620
|
+
* cuelga del scope (`scope#binding`) y apunta a su rol (`role_binding#role`).
|
|
621
|
+
* Sin ellas el `assignee` es un hecho huérfano que `can_<P>` no ve — es la
|
|
622
|
+
* diferencia entre el modo `resolver` (donde la cadena la expande el paquete
|
|
623
|
+
* y basta con el `assignee`) y el modo `facts`.
|
|
624
|
+
*
|
|
625
|
+
* Son ESTRUCTURA, no concesión: no llevan caducidad y no dicen quién está
|
|
626
|
+
* asignado. Por eso `revoke` no las borra (otro holder puede seguir usando el
|
|
627
|
+
* mismo binding) y re-escribirlas es idempotente.
|
|
628
|
+
*/
|
|
629
|
+
export function factsBindingTuples(scopeKeyValue, roleUuid) {
|
|
630
|
+
const object = factsBindingObject(scopeKeyValue, roleUuid);
|
|
631
|
+
const role = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
632
|
+
assertFgaObjectId(FACTS_ROLE_TYPE, role);
|
|
633
|
+
return [
|
|
634
|
+
{ user: role, relation: FACTS_ROLE_RELATION, object },
|
|
635
|
+
factsScopeBindingTuple(scopeKeyValue, roleUuid),
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* La arista `scope:<key>#binding@role_binding:<key>|<rol>` sola, que es la
|
|
640
|
+
* que hace ALCANZABLE la asignación desde el scope.
|
|
641
|
+
*
|
|
642
|
+
* **Qué significa** (3b-2g · R1, decisión del dueño del 2026-08-30 (2)): desde
|
|
643
|
+
* el barrido, «**el rol es visible aquí**» — no «esta asignación existe». El
|
|
644
|
+
* hecho de la asignación es el `assignee`, que el barrido no toca; esta arista
|
|
645
|
+
* la escribe y la borra el paquete cada vez que la REGLA DE VISIBILIDAD cambia
|
|
646
|
+
* de respuesta para ese `(rol, scope)`: el árbol se mueve y el owner deja de
|
|
647
|
+
* estar en la cadena (`scopes.moved`, 3b-2e · E1) o el catálogo cambia el
|
|
648
|
+
* NIVEL declarado del rol (`projectCatalogRole`, 3b-2g · R1). Es lo que en
|
|
649
|
+
* `database` se evalúa en cada pregunta con `declaredRoleAt`, y aquí hay que
|
|
650
|
+
* materializar porque el modelo (c2) no lleva ni el owner ni el nivel.
|
|
651
|
+
*/
|
|
652
|
+
export function factsScopeBindingTuple(scopeKeyValue, roleUuid) {
|
|
653
|
+
return {
|
|
654
|
+
user: factsBindingObject(scopeKeyValue, roleUuid),
|
|
655
|
+
relation: FACTS_BINDING_RELATION,
|
|
656
|
+
object: factsScopeObject(scopeKeyValue),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* El deny explícito de (c2): `scope:<key>#denied_<P>@<holder>`. Ya no existe
|
|
661
|
+
* el tipo `deny_binding` — el deny es una relación DEL SCOPE, que es lo que
|
|
662
|
+
* permite que `denied_<P>` se herede hacia abajo por `parent` dentro del
|
|
663
|
+
* propio modelo y que `can_<P>` sea la resta (invariante 2) en un solo Check.
|
|
664
|
+
*/
|
|
665
|
+
export function factsDenyTuple(scopeKeyValue, permission, user) {
|
|
666
|
+
return {
|
|
667
|
+
user,
|
|
668
|
+
relation: factsRelationsOf(permission).denied,
|
|
669
|
+
object: factsScopeObject(scopeKeyValue),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** Prefijo de la familia del deny (`denied_<P>`), para leer denies por relación. */
|
|
673
|
+
export const FACTS_DENIED_PREFIX = 'denied_';
|
|
674
|
+
/* ── Relaciones ReBAC FUSIONADAS en el modelo (Fase 4-1) ─────────────────── */
|
|
675
|
+
/**
|
|
676
|
+
* El tipo `group` de las relaciones ReBAC: el portador de los usersets
|
|
677
|
+
* (`group:eng#member`) que hace que un `viewer` valga para todos los miembros
|
|
678
|
+
* de un grupo. Lo emite SIEMPRE el generador de relaciones (no lo declara el
|
|
679
|
+
* consumidor), y por eso es un tipo RESERVADO igual que los de `facts`.
|
|
680
|
+
*/
|
|
681
|
+
export const FACTS_GROUP_TYPE = 'group';
|
|
682
|
+
/** La relación de pertenencia del grupo: `group:<id>#member@<holder>` y `@group:<otro>#member`. */
|
|
683
|
+
export const FACTS_GROUP_MEMBER_RELATION = 'member';
|
|
684
|
+
/**
|
|
685
|
+
* Los tipos reservados del modelo compartido (⚪4). La fuente única está en
|
|
686
|
+
* `src/identity.ts` (la gramática compartida), porque también la consume
|
|
687
|
+
* `defineRelationsConfig` en `src/relations/`, que la frontera de pureza
|
|
688
|
+
* mantiene disjunto de `drivers/`. Se re-exporta aquí para no romper el
|
|
689
|
+
* subpath `/openfga` (`src/openfga.ts` la publica desde este módulo).
|
|
690
|
+
*/
|
|
691
|
+
export { RESERVED_FACTS_TYPES } from '../identity.js';
|
|
692
|
+
/**
|
|
693
|
+
* **⚪4 + F-04, a nivel de MODELO.** Antes de emitir un solo `type_definition`
|
|
694
|
+
* de relaciones, el generador comprueba que la config es FUSIONABLE en el
|
|
695
|
+
* modelo compartido:
|
|
696
|
+
*
|
|
697
|
+
* - ningún `objectType` duplica un tipo reservado de `facts`/`group` ni un
|
|
698
|
+
* holder type (⚪4 · tipo), ni se repite;
|
|
699
|
+
* - ningún NOMBRE de relación (deduplicado entre tipos: `document#viewer` y
|
|
700
|
+
* `folder#viewer` son la misma relación lógica y se permiten) empieza por
|
|
701
|
+
* una familia derivada (`can_`/`denied_`/`permits_`) ni coincide con una
|
|
702
|
+
* relación PROPIA del modelo (`parent`/`rooted`/`assignee`… — ⚪4 · familia)
|
|
703
|
+
* ni con un permiso del catálogo (F-04);
|
|
704
|
+
* - los `includes` refieren relaciones del MISMO tipo (un nivel).
|
|
705
|
+
*
|
|
706
|
+
* Lanza 422 `E_AUTHZ_RELATION_CONFIG` del PAQUETE (no el 400 opaco del
|
|
707
|
+
* servidor), nombrando qué choca con qué.
|
|
708
|
+
*/
|
|
709
|
+
export function assertRelationsConfigPublishable(permissionRelations, config, holderTypes) {
|
|
710
|
+
// El espacio de nombres PLANO de `facts`: relaciones propias del modelo +
|
|
711
|
+
// las cuatro familias de cada permiso. Es el mismo criterio que
|
|
712
|
+
// `factsRelationMap` (A2/S4): aunque las relaciones vivan en tipos distintos,
|
|
713
|
+
// se tratan como un solo espacio para que el namespace de relaciones quede
|
|
714
|
+
// DEMOSTRABLEMENTE disjunto del de `facts` (cierre por construcción).
|
|
715
|
+
const origin = new Map(OWN_RELATIONS);
|
|
716
|
+
for (const r of permissionRelations) {
|
|
717
|
+
for (const name of [r.base, r.can, r.denied, r.permits]) {
|
|
718
|
+
origin.set(name, `el permiso '${r.permission}'`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const reservedTypes = new Set([...RESERVED_FACTS_TYPES, ...holderTypes]);
|
|
722
|
+
const seenTypes = new Set();
|
|
723
|
+
const relationNames = new Set();
|
|
724
|
+
for (const objectType of config.objectTypes) {
|
|
725
|
+
const type = objectType?.type;
|
|
726
|
+
if (typeof type !== 'string' || !FGA_TYPE_FORMAT.test(type)) {
|
|
727
|
+
throw new RelationConfigError(`Tipo de objeto de relaciones inválido: ${JSON.stringify(type)} no es un tipo FGA válido ` +
|
|
728
|
+
`(1-254 caracteres, sin ':', '#', '@' ni espacios).`);
|
|
729
|
+
}
|
|
730
|
+
if (reservedTypes.has(type)) {
|
|
731
|
+
throw new RelationConfigError(`El tipo de objeto de relaciones '${type}' colisiona con un tipo reservado del modelo compartido ` +
|
|
732
|
+
`(${[...RESERVED_FACTS_TYPES].join(', ')}${holderTypes.length ? ', y los holder types ' + holderTypes.join(', ') : ''}). ` +
|
|
733
|
+
`En el store compartido un tipo de relaciones no puede duplicar uno de 'facts' (⚪4): renómbralo.`);
|
|
734
|
+
}
|
|
735
|
+
if (seenTypes.has(type)) {
|
|
736
|
+
throw new RelationConfigError(`El tipo de objeto de relaciones '${type}' está declarado dos veces.`);
|
|
737
|
+
}
|
|
738
|
+
seenTypes.add(type);
|
|
739
|
+
const own = new Set();
|
|
740
|
+
for (const relation of objectType.relations ?? []) {
|
|
741
|
+
const name = relation?.name;
|
|
742
|
+
if (typeof name !== 'string' || !FGA_TYPE_FORMAT.test(name)) {
|
|
743
|
+
throw new RelationConfigError(`Relación inválida en el tipo '${type}': ${JSON.stringify(name)} no es un nombre de relación válido.`);
|
|
744
|
+
}
|
|
745
|
+
own.add(name);
|
|
746
|
+
relationNames.add(name);
|
|
747
|
+
}
|
|
748
|
+
// Los `includes` refieren relaciones del MISMO tipo (un nivel, sin `from`).
|
|
749
|
+
for (const relation of objectType.relations ?? []) {
|
|
750
|
+
for (const included of relation.includes ?? []) {
|
|
751
|
+
if (!own.has(included)) {
|
|
752
|
+
throw new RelationConfigError(`La relación '${type}#${relation.name}' incluye '${included}', que no es una relación de '${type}'. ` +
|
|
753
|
+
`Los includes son de un nivel y del mismo tipo (v1 no soporta 'from').`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
for (const name of relationNames) {
|
|
759
|
+
if (name.length > FGA_MAX_RELATION_NAME) {
|
|
760
|
+
throw new RelationConfigError(`La relación '${name}' tiene ${name.length} caracteres y FGA admite ${FGA_MAX_RELATION_NAME}.`);
|
|
761
|
+
}
|
|
762
|
+
const family = RESERVED_SLUG_PREFIXES.find((prefix) => name.startsWith(prefix));
|
|
763
|
+
if (family) {
|
|
764
|
+
throw new RelationConfigError(`La relación '${name}' empieza por '${family}', prefijo reservado de las relaciones derivadas del modelo ` +
|
|
765
|
+
`facts (${RESERVED_SLUG_PREFIXES.join(', ')}): elegiría el nombre de un permiso proyectado (⚪4).`);
|
|
766
|
+
}
|
|
767
|
+
const clash = origin.get(name);
|
|
768
|
+
if (clash !== undefined) {
|
|
769
|
+
throw new RelationConfigError(`Colisión de nombres en el modelo compartido: la relación de objeto '${name}' ya la usa ${clash} ` +
|
|
770
|
+
`en 'facts'. El espacio de relaciones de 'relations/' y el de 'facts' tienen que ser disjuntos ` +
|
|
771
|
+
`(⚪4 para una relación propia del modelo, F-04 para un permiso del catálogo): renombra la relación.`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Los `type_definitions` de relaciones que el generador AÑADE al modelo
|
|
777
|
+
* `facts`: el tipo `group` (usersets) + un tipo por objeto declarado. El
|
|
778
|
+
* literal medido contra el `:8101` está en la §1 del plan de la Fase 4.
|
|
779
|
+
*
|
|
780
|
+
* `group.member` admite holders directos y `group#member` (grupos anidan un
|
|
781
|
+
* nivel); cada relación de objeto admite `[holders, group#member]` directos y,
|
|
782
|
+
* si declara `includes`, se une a las relaciones incluidas del mismo tipo
|
|
783
|
+
* (`viewer or editor`), sin una sola tupla extra.
|
|
784
|
+
*/
|
|
785
|
+
export function factsRelationTypeDefinitions(permissionRelations, config, holderTypes) {
|
|
786
|
+
assertRelationsConfigPublishable(permissionRelations, config, holderTypes);
|
|
787
|
+
const direct = holderTypes.map((type) => ({ type }));
|
|
788
|
+
// `[user, admin, integration, group#member]`: los holders y el userset del grupo.
|
|
789
|
+
const holdersOrGroup = [...direct, { type: FACTS_GROUP_TYPE, relation: FACTS_GROUP_MEMBER_RELATION }];
|
|
790
|
+
const definitions = [
|
|
791
|
+
{
|
|
792
|
+
type: FACTS_GROUP_TYPE,
|
|
793
|
+
relations: { [FACTS_GROUP_MEMBER_RELATION]: { this: {} } },
|
|
794
|
+
metadata: {
|
|
795
|
+
relations: { [FACTS_GROUP_MEMBER_RELATION]: { directly_related_user_types: holdersOrGroup } },
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
];
|
|
799
|
+
for (const objectType of config.objectTypes) {
|
|
800
|
+
const relations = {};
|
|
801
|
+
const metadata = {};
|
|
802
|
+
for (const relation of objectType.relations) {
|
|
803
|
+
const includes = relation.includes ?? [];
|
|
804
|
+
relations[relation.name] = includes.length
|
|
805
|
+
? { union: { child: [{ this: {} }, ...includes.map((name) => computed(name))] } }
|
|
806
|
+
: { this: {} };
|
|
807
|
+
metadata[relation.name] = { directly_related_user_types: holdersOrGroup };
|
|
808
|
+
}
|
|
809
|
+
definitions.push({ type: objectType.type, relations, metadata: { relations: metadata } });
|
|
810
|
+
}
|
|
811
|
+
return definitions;
|
|
812
|
+
}
|
|
813
|
+
//# sourceMappingURL=openfga_facts.js.map
|