@zanzojs/core 0.1.0-beta.0
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 +21 -0
- package/README.md +123 -0
- package/dist/index.cjs +546 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +407 -0
- package/dist/index.d.ts +407 -0
- package/dist/index.js +533 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gonzalo Jeria
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Zanzo 🛡️
|
|
2
|
+
|
|
3
|
+
**Librería de autorización ReBAC (Relationship-Based Access Control) isomórfica para TypeScript. Inspirada en Google Zanzibar. 0 dependencias. Type-safe.**
|
|
4
|
+
|
|
5
|
+
Zanzo separa la complejidad del servidor (resolución de grafos relaciones) de la ligereza del cliente (Frontend / Edge), ofreciendo una Developer Experience (DX) inigualable mediante tipado estricto y utilidades de Query Pushdown.
|
|
6
|
+
|
|
7
|
+
## 🌟 Características Principales
|
|
8
|
+
|
|
9
|
+
- 🚀 **Isomórfico y 0 Dependencias:** Funciona nativamente en Node, Edge, Deno, Bun y Browser.
|
|
10
|
+
- 🔒 **Type-Safe (Generics Avanzados):** Inferencia estricta de strings y acciones usando TypeScript avanzado. El compilador sabe exactamente qué puedes hacer y dónde.
|
|
11
|
+
- 🌳 **Query Pushdown (AST):** No cargues miles de registros en memoria. Genera un Abstract Syntax Tree (AST) diagnóstico para que tu base de datos (Prisma, Mongo, SQL) haga el trabajo pesado de filtrar permisos.
|
|
12
|
+
- ⚡ **Frontend Ultraligero:** Evaluación `O(1)` asíncrona o estática en el cliente usando mapeos planos JSON (o Bitmasks).
|
|
13
|
+
- 🔄 **Graph Resolution & Relational Tuples:** Permite notación de puntos (ej. `team.member`) analizando tuplas en memoria de manera recursiva sin caer en bucles infinitos (Ciclic Safe).
|
|
14
|
+
|
|
15
|
+
## 📦 Instalación
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install zanzo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 🛠️ Cómo funciona (El Ciclo de Vida en Código)
|
|
22
|
+
|
|
23
|
+
Zanzo se diseña para abarcar todo el ecosistema de tu aplicación, conectando Frontend, Backend y Bases de Datos a través del mismo contrato de tipos.
|
|
24
|
+
|
|
25
|
+
### Paso 1: Define tu esquema (`ZanzoBuilder`)
|
|
26
|
+
|
|
27
|
+
Toda tu política de seguridad está centralizada en un esquema robusto y tipado, que se usará para retro-alimentar todas las comprobaciones a lo largo del stack:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { ZanzoBuilder } from '@zanzojs/core';
|
|
31
|
+
|
|
32
|
+
const schema = new ZanzoBuilder()
|
|
33
|
+
.entity('User', { actions: [] as const })
|
|
34
|
+
.entity('Group', {
|
|
35
|
+
actions: [] as const,
|
|
36
|
+
relations: { member: 'User' } as const,
|
|
37
|
+
})
|
|
38
|
+
.entity('Document', {
|
|
39
|
+
actions: ['read', 'write', 'delete'] as const,
|
|
40
|
+
relations: { owner: 'User', creator: 'User', team: 'Group' } as const,
|
|
41
|
+
permissions: {
|
|
42
|
+
// Define who can do what with direct relations and dot-notation inheritance
|
|
43
|
+
read: ['owner', 'creator', 'team.member'],
|
|
44
|
+
write: ['owner'],
|
|
45
|
+
delete: [] // Admins super-pass this or explicitly disallow
|
|
46
|
+
} as const,
|
|
47
|
+
})
|
|
48
|
+
.build();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Paso 2: Valida en el Backend (`ZanzoEngine`)
|
|
52
|
+
|
|
53
|
+
En memoria o evaluando tuplas interceptadas (útil para casos sencillos y endpoints seguros):
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { ZanzoEngine } from '@zanzojs/core';
|
|
57
|
+
|
|
58
|
+
const engine = new ZanzoEngine(schema);
|
|
59
|
+
|
|
60
|
+
// Inyecta tuplas desde tu DB (Relation Tuples)
|
|
61
|
+
engine.addTuple({ subject: 'User:Ana', relation: 'owner', object: 'Document:123' });
|
|
62
|
+
engine.addTuple({ subject: 'User:Carlos', relation: 'member', object: 'Group:Devs' });
|
|
63
|
+
engine.addTuple({ subject: 'Group:Devs', relation: 'team', object: 'Document:456' });
|
|
64
|
+
|
|
65
|
+
// TS Autocomplete estricto: ¿Puede Ana editar el documento?
|
|
66
|
+
engine.can('User:Ana', 'write', 'Document:123'); // true
|
|
67
|
+
|
|
68
|
+
// ¿Puede Carlos leer el documento dev a través del grupo?
|
|
69
|
+
engine.can('User:Carlos', 'read', 'Document:456'); // true
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Paso 3: Consulta a tu Base de Datos (`AST / Query Pushdown`)
|
|
73
|
+
|
|
74
|
+
Si la evaluación requiere una lista de documentos que el usuario *sí* puede ver para hacer una consulta a una BD con medio millón de registros, no cargues memoria. Pídele el `AST` al Engine para delegar el filtro al ORM:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// ¿Qué patrón deben cumplir los registros para que Ana pueda leer un Document?
|
|
78
|
+
const queryAst = engine.buildDatabaseQuery('User:Ana', 'read', 'Document');
|
|
79
|
+
|
|
80
|
+
/* Result:
|
|
81
|
+
{
|
|
82
|
+
operator: 'OR',
|
|
83
|
+
conditions: [
|
|
84
|
+
{ type: 'direct', relation: 'owner', targetSubject: 'User:Ana' },
|
|
85
|
+
{ type: 'direct', relation: 'creator', targetSubject: 'User:Ana' },
|
|
86
|
+
{ type: 'nested', relation: 'team', nextRelationPath: ['member'], targetSubject: 'User:Ana' }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
*/
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
*(La comunidad puede extender este AST para crear adaptadores directo a Prisma, Drizzle, TypeORM, Mongo, etc.)*
|
|
93
|
+
|
|
94
|
+
### Paso 4: Envía al Frontend (`Client Editor Edge Compiled`)
|
|
95
|
+
|
|
96
|
+
Al iniciar la sesión del usuario o refrescar datos (SSR / Next.js), compila el grafo ReBAC súper pesado del servidor en un JSON estático y ultrarapido para mandar al cliente:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { createZanzoSnapshot, ZanzoClient } from '@zanzojs/core';
|
|
100
|
+
|
|
101
|
+
// Backend:
|
|
102
|
+
const flatPermissions = createZanzoSnapshot(engine, 'User:Carlos');
|
|
103
|
+
// flatPermissions = { "Document:456": ["read"] }
|
|
104
|
+
|
|
105
|
+
// --- Envía el JSON por API ---
|
|
106
|
+
|
|
107
|
+
// Frontend (React, Vue, Edge Worker, etc.):
|
|
108
|
+
// ¡0 Dependencias, 0 Conocimiento del Graph!
|
|
109
|
+
const client = new ZanzoClient(flatPermissions);
|
|
110
|
+
|
|
111
|
+
// Operación inmediata O(1)
|
|
112
|
+
if (client.can('read', 'Document:456')) {
|
|
113
|
+
// Show UI Component
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 🚀 Próximos pasos / Adaptadores
|
|
118
|
+
|
|
119
|
+
Zanzo fue construido para ser agnóstico. El output de `buildDatabaseQuery()` está intencionalmente diseñado para que la comunidad (o tú) puedas implementar compiladores finales o adaptadores que traduzcan la representación analítica del AST en Data Queries nativas directas para integraciones específicas: `@zanzo/prisma`, `@zanzo/drizzle`, etc.
|
|
120
|
+
|
|
121
|
+
## 📜 Licencia
|
|
122
|
+
|
|
123
|
+
[MIT License](LICENSE) © Zanzo Contributors
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/ref/index.ts
|
|
4
|
+
var ENTITY_REF_SEPARATOR = ":";
|
|
5
|
+
var RELATION_PATH_SEPARATOR = ".";
|
|
6
|
+
var CONTROL_CHARS_REGEX = /[\x00-\x1F\x7F]/;
|
|
7
|
+
function parseEntityRef(raw) {
|
|
8
|
+
if (!raw || typeof raw !== "string") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`[Zanzo] Invalid EntityRef: received ${raw === "" ? "empty string" : String(raw)}. Expected a non-empty string in "Type:ID" format.`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
if (raw.length > 255) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`[Zanzo] Invalid EntityRef: input exceeds 255 characters (got ${raw.length}). Entity references must be under 255 characters.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (CONTROL_CHARS_REGEX.test(raw)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`[Zanzo] Invalid EntityRef: input contains illegal unprintable control characters. Sanitize the input before creating an EntityRef.`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const sepIndex = raw.indexOf(ENTITY_REF_SEPARATOR);
|
|
24
|
+
if (sepIndex === -1) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`[Zanzo] Invalid EntityRef: "${raw}" does not contain a '${ENTITY_REF_SEPARATOR}' separator. Expected format is "Type:ID" (e.g. "User:123").`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (raw.indexOf(ENTITY_REF_SEPARATOR, sepIndex + 1) !== -1) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[Zanzo] Invalid EntityRef: "${raw}" contains multiple '${ENTITY_REF_SEPARATOR}' separators. Expected exactly one separator in "Type:ID" format.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const type = raw.substring(0, sepIndex);
|
|
35
|
+
const id = raw.substring(sepIndex + 1);
|
|
36
|
+
if (type.length === 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`[Zanzo] Invalid EntityRef: "${raw}" has an empty type segment. The type before '${ENTITY_REF_SEPARATOR}' must be non-empty (e.g. "User:123").`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (id.length === 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[Zanzo] Invalid EntityRef: "${raw}" has an empty id segment. The id after '${ENTITY_REF_SEPARATOR}' must be non-empty (e.g. "User:123").`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return { type, id };
|
|
47
|
+
}
|
|
48
|
+
function serializeEntityRef(entityRef) {
|
|
49
|
+
return `${entityRef.type}${ENTITY_REF_SEPARATOR}${entityRef.id}`;
|
|
50
|
+
}
|
|
51
|
+
function ref(raw) {
|
|
52
|
+
return parseEntityRef(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/builder/index.ts
|
|
56
|
+
var ZanzoBuilder = class _ZanzoBuilder {
|
|
57
|
+
schema;
|
|
58
|
+
constructor(initialSchema) {
|
|
59
|
+
this.schema = initialSchema ?? {};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Defines a new entity (resource) in the schema.
|
|
63
|
+
*
|
|
64
|
+
* @param name The name of the entity resource (e.g., 'User', 'Project')
|
|
65
|
+
* @param definition The definition containing allowed actions and relations.
|
|
66
|
+
* @returns A new ZanzoBuilder instance carrying the expanded type information.
|
|
67
|
+
*/
|
|
68
|
+
entity(name, definition) {
|
|
69
|
+
const newSchema = {
|
|
70
|
+
...this.schema,
|
|
71
|
+
[name]: {
|
|
72
|
+
actions: [...definition.actions],
|
|
73
|
+
// Default to empty object if no relations provided to maintain stable structure
|
|
74
|
+
relations: definition.relations ? { ...definition.relations } : {},
|
|
75
|
+
permissions: definition.permissions ? Object.fromEntries(
|
|
76
|
+
Object.entries(definition.permissions).map(([action, relations]) => [
|
|
77
|
+
action,
|
|
78
|
+
[...relations]
|
|
79
|
+
])
|
|
80
|
+
) : {}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return new _ZanzoBuilder(newSchema);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Builds and freezes the schema, preventing further modifications.
|
|
87
|
+
*
|
|
88
|
+
* @returns The immutable, frozen ReBAC schema.
|
|
89
|
+
*/
|
|
90
|
+
build() {
|
|
91
|
+
const deepFreeze = (obj) => {
|
|
92
|
+
Object.keys(obj).forEach((prop) => {
|
|
93
|
+
if (typeof obj[prop] === "object" && obj[prop] !== null && !Object.isFrozen(obj[prop])) {
|
|
94
|
+
deepFreeze(obj[prop]);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return Object.freeze(obj);
|
|
98
|
+
};
|
|
99
|
+
return deepFreeze(this.schema);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
function mergeSchemas(...schemas) {
|
|
103
|
+
const unified = {};
|
|
104
|
+
for (const schema of schemas) {
|
|
105
|
+
for (const [entityName, definition] of Object.entries(schema)) {
|
|
106
|
+
if (unified[entityName]) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[Zanzo] Schema Merge Collision: The entity '${entityName}' is defined in multiple schemas. Please ensure your domain segments are uniquely scoped.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
unified[entityName] = definition;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze(unified);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/engine/index.ts
|
|
118
|
+
var ZanzoEngine = class {
|
|
119
|
+
schema;
|
|
120
|
+
// Map<ObjectIdentifier, Map<Relation, Set<SubjectIdentifier>>>
|
|
121
|
+
index = /* @__PURE__ */ new Map();
|
|
122
|
+
constructor(schema) {
|
|
123
|
+
this.schema = schema;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Retreives the readonly schema structure.
|
|
127
|
+
*/
|
|
128
|
+
getSchema() {
|
|
129
|
+
return this.schema;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Retrieves the read-only relation-graph maps indexing memory objects.
|
|
133
|
+
* Exposing strictly for flat compilers.
|
|
134
|
+
*/
|
|
135
|
+
getIndex() {
|
|
136
|
+
return this.index;
|
|
137
|
+
}
|
|
138
|
+
// ZANZO-REVIEW: Extraído según la especificación (validateActorInput).
|
|
139
|
+
// Nota: hemos agrupado `resourceType` bajo su propia directriz, pero mantenemos esta abstracción idéntica
|
|
140
|
+
// a cómo se extrae la validación limpia del actor tal y como solicitaste.
|
|
141
|
+
// Issue #9: Unified validation — previously duplicated between actor and resource validators.
|
|
142
|
+
validateInput(input, label) {
|
|
143
|
+
if (!input || typeof input !== "string" || input.length > 255) {
|
|
144
|
+
throw new Error(`[Zanzo] Invalid ${label} input. Must be a non-empty string under 255 characters.`);
|
|
145
|
+
}
|
|
146
|
+
const controlCharsRegex = /[\x00-\x1F\x7F]/;
|
|
147
|
+
if (controlCharsRegex.test(input)) {
|
|
148
|
+
throw new Error(`[Zanzo] Security Exception: ${label} input contains illegal unprintable control characters.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Injects a relation tuple into the in-memory store.
|
|
153
|
+
* Issue #3: Validates all tuple fields before storing to prevent graph poisoning.
|
|
154
|
+
*/
|
|
155
|
+
addTuple(tuple) {
|
|
156
|
+
this.validateInput(tuple.subject, "subject");
|
|
157
|
+
this.validateInput(tuple.object, "object");
|
|
158
|
+
this.validateInput(tuple.relation, "relation");
|
|
159
|
+
let objectRelations = this.index.get(tuple.object);
|
|
160
|
+
if (!objectRelations) {
|
|
161
|
+
objectRelations = /* @__PURE__ */ new Map();
|
|
162
|
+
this.index.set(tuple.object, objectRelations);
|
|
163
|
+
}
|
|
164
|
+
let subjectsSet = objectRelations.get(tuple.relation);
|
|
165
|
+
if (!subjectsSet) {
|
|
166
|
+
subjectsSet = /* @__PURE__ */ new Set();
|
|
167
|
+
objectRelations.set(tuple.relation, subjectsSet);
|
|
168
|
+
}
|
|
169
|
+
subjectsSet.add(tuple.subject);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Injects multiple relation tuples into the in-memory store.
|
|
173
|
+
*/
|
|
174
|
+
addTuples(tuples) {
|
|
175
|
+
for (const tuple of tuples) {
|
|
176
|
+
this.addTuple(tuple);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Clears all relation tuples in the memory store.
|
|
181
|
+
*/
|
|
182
|
+
clearTuples() {
|
|
183
|
+
this.index.clear();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* PERF-2: Evaluates ALL actions for a given actor on a specific resource in a
|
|
187
|
+
* single pass. Returns the list of granted actions.
|
|
188
|
+
*
|
|
189
|
+
* This is more efficient than calling can() per action because:
|
|
190
|
+
* - Identical routes shared by multiple actions are evaluated only once
|
|
191
|
+
* - Early exit when all actions are already resolved
|
|
192
|
+
* - Only one validation pass per (actor, resource) pair
|
|
193
|
+
*
|
|
194
|
+
* @internal This method is public solely because `createZanzoSnapshot` (in compiler/)
|
|
195
|
+
* requires access to it. It is NOT part of the public API contract and may change
|
|
196
|
+
* without notice in any minor version. Making it private would require moving
|
|
197
|
+
* `createZanzoSnapshot` into ZanzoEngine as a method, which would break the current
|
|
198
|
+
* modular architecture where the compiler is a standalone pure function.
|
|
199
|
+
*/
|
|
200
|
+
evaluateAllActions(actor, resource) {
|
|
201
|
+
this.validateInput(actor, "actor");
|
|
202
|
+
this.validateInput(resource, "resource");
|
|
203
|
+
const resourceType = parseEntityRef(resource).type;
|
|
204
|
+
const resourceSchema = this.schema[resourceType];
|
|
205
|
+
if (!resourceSchema) return [];
|
|
206
|
+
const actions = resourceSchema.actions;
|
|
207
|
+
if (!actions || actions.length === 0) return [];
|
|
208
|
+
const permissions = resourceSchema.permissions;
|
|
209
|
+
if (!permissions) return [];
|
|
210
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
211
|
+
for (const action of actions) {
|
|
212
|
+
const relationsForAction = permissions[action];
|
|
213
|
+
if (!relationsForAction || relationsForAction.length === 0) continue;
|
|
214
|
+
for (const route of relationsForAction) {
|
|
215
|
+
let entry = routeMap.get(route);
|
|
216
|
+
if (!entry) {
|
|
217
|
+
entry = { parts: route.split(RELATION_PATH_SEPARATOR), actions: /* @__PURE__ */ new Set() };
|
|
218
|
+
routeMap.set(route, entry);
|
|
219
|
+
}
|
|
220
|
+
entry.actions.add(action);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (routeMap.size === 0) return [];
|
|
224
|
+
const grantedActions = /* @__PURE__ */ new Set();
|
|
225
|
+
for (const { parts, actions: routeActions } of routeMap.values()) {
|
|
226
|
+
const allAlreadyGranted = [...routeActions].every((a) => grantedActions.has(a));
|
|
227
|
+
if (allAlreadyGranted) continue;
|
|
228
|
+
const resolved = this.checkRelationsRecursive(
|
|
229
|
+
actor,
|
|
230
|
+
[parts],
|
|
231
|
+
resource,
|
|
232
|
+
/* @__PURE__ */ new Set(),
|
|
233
|
+
0
|
|
234
|
+
);
|
|
235
|
+
if (resolved) {
|
|
236
|
+
for (const action of routeActions) {
|
|
237
|
+
grantedActions.add(action);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (grantedActions.size === actions.length) break;
|
|
241
|
+
}
|
|
242
|
+
return actions.filter((a) => grantedActions.has(a));
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Evaluates if a given actor has permission to perform an action on a specific resource.
|
|
246
|
+
* Leverages TypeScript assertions to provide strict autocompletion based on the schema.
|
|
247
|
+
*
|
|
248
|
+
* @param actor The subject entity string identifier (e.g., 'User:1')
|
|
249
|
+
* @param action The specific action to perform (e.g., 'edit'), strictly typed.
|
|
250
|
+
* @param resource The target resource entity string identifier (e.g., 'Project:A')
|
|
251
|
+
* @returns boolean True if authorized, false otherwise.
|
|
252
|
+
*/
|
|
253
|
+
can(actor, action, resource) {
|
|
254
|
+
this.validateInput(actor, "actor");
|
|
255
|
+
this.validateInput(resource, "resource");
|
|
256
|
+
const resourceType = parseEntityRef(resource).type;
|
|
257
|
+
const resourceSchema = this.schema[resourceType];
|
|
258
|
+
if (!resourceSchema || !resourceSchema.actions.includes(action)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
const allowedRelationsForAction = resourceSchema.permissions?.[action] || [];
|
|
262
|
+
if (allowedRelationsForAction.length === 0) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const preSplitRoutes = allowedRelationsForAction.map((route) => route.split(RELATION_PATH_SEPARATOR));
|
|
266
|
+
return this.checkRelationsRecursive(actor, preSplitRoutes, resource, /* @__PURE__ */ new Set(), 0);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Internal recursive relation evaluation algorithm via Map Indexes.
|
|
270
|
+
*
|
|
271
|
+
* @param actor The original actor trying to accomplish the task
|
|
272
|
+
* @param allowedRoutes Array of relation chains (pre-splitted parts) that grant access
|
|
273
|
+
* @param currentTarget The current entity node in the graph being evaluated
|
|
274
|
+
* @param visited Set of visited nodes to prevent cycles in graph evaluation
|
|
275
|
+
* @returns True if relation path connects target to actor
|
|
276
|
+
*/
|
|
277
|
+
checkRelationsRecursive(actor, allowedRoutes, currentTarget, visited, depth = 0, parentSignature = "") {
|
|
278
|
+
if (depth > 50) {
|
|
279
|
+
throw new Error(`[Zanzo] Security Exception: Maximum relationship depth of 50 exceeded. Graph might contain an infinite cycle or is too heavily nested.`);
|
|
280
|
+
}
|
|
281
|
+
const visitedSignature = `${actor}|${currentTarget}|${parentSignature}`;
|
|
282
|
+
if (visited.has(visitedSignature)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
visited.add(visitedSignature);
|
|
286
|
+
const targetRelationsIndex = this.index.get(currentTarget);
|
|
287
|
+
if (!targetRelationsIndex) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
for (let i = 0; i < allowedRoutes.length; i++) {
|
|
291
|
+
const routeParts = allowedRoutes[i];
|
|
292
|
+
const currentRelation = routeParts[0];
|
|
293
|
+
const subjectsForRelation = targetRelationsIndex.get(currentRelation);
|
|
294
|
+
if (!subjectsForRelation || subjectsForRelation.size === 0) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (routeParts.length === 1) {
|
|
298
|
+
if (subjectsForRelation.has(actor)) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
const remainingRoute = routeParts.slice(1);
|
|
303
|
+
const nextSignature = parentSignature ? parentSignature + "." + currentRelation + `[${i}]` : currentRelation + `[${i}]`;
|
|
304
|
+
for (const intermediateSubject of subjectsForRelation) {
|
|
305
|
+
const isGranted = this.checkRelationsRecursive(
|
|
306
|
+
actor,
|
|
307
|
+
[remainingRoute],
|
|
308
|
+
// Pass down the remaining route only
|
|
309
|
+
intermediateSubject,
|
|
310
|
+
visited,
|
|
311
|
+
depth + 1,
|
|
312
|
+
nextSignature
|
|
313
|
+
);
|
|
314
|
+
if (isGranted) return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Generates a database-agnostic Abstract Syntax Tree (AST) representing
|
|
322
|
+
* the logical query needed to verify if the given actor is authorized to
|
|
323
|
+
* perform action on a specific resourceType.
|
|
324
|
+
*
|
|
325
|
+
* Useful for "Query Pushdown", allowing ORMs or databases to evaluate permissions
|
|
326
|
+
* directly across their own relational tables instead of loading data into memory.
|
|
327
|
+
*
|
|
328
|
+
* @param actor The subject entity string identifier (e.g., 'User:1')
|
|
329
|
+
* @param action The specific action to perform (e.g., 'read'), strictly typed.
|
|
330
|
+
* @param resourceType The target resource entity TYPE (e.g., 'Project')
|
|
331
|
+
* @returns QueryAST block if action is valid and has mapped relations, null otherwise.
|
|
332
|
+
*/
|
|
333
|
+
buildDatabaseQuery(actor, action, resourceType) {
|
|
334
|
+
this.validateInput(actor, "actor");
|
|
335
|
+
this.validateInput(resourceType, "resource");
|
|
336
|
+
const resourceSchema = this.schema[resourceType];
|
|
337
|
+
if (!resourceSchema || !resourceSchema.actions.includes(action)) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const allowedRelationsForAction = resourceSchema.permissions?.[action] || [];
|
|
341
|
+
if (allowedRelationsForAction.length === 0) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const conditions = allowedRelationsForAction.map(
|
|
345
|
+
(routeLine) => {
|
|
346
|
+
const parts = routeLine.split(RELATION_PATH_SEPARATOR);
|
|
347
|
+
if (parts.length === 1) {
|
|
348
|
+
return {
|
|
349
|
+
type: "direct",
|
|
350
|
+
relation: parts[0],
|
|
351
|
+
targetSubject: actor
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
type: "nested",
|
|
356
|
+
relation: parts[0],
|
|
357
|
+
nextRelationPath: parts.slice(1),
|
|
358
|
+
targetSubject: actor
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
return {
|
|
363
|
+
operator: "OR",
|
|
364
|
+
// ReBAC normally operates on union of granted authority paths
|
|
365
|
+
conditions
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// src/compiler/index.ts
|
|
371
|
+
function createZanzoSnapshot(engine, actor) {
|
|
372
|
+
const result = /* @__PURE__ */ Object.create(null);
|
|
373
|
+
const index = engine.getIndex();
|
|
374
|
+
for (const resource of index.keys()) {
|
|
375
|
+
const allowedActions = engine.evaluateAllActions(actor, resource);
|
|
376
|
+
if (allowedActions.length > 0) {
|
|
377
|
+
result[resource] = allowedActions;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/client/index.ts
|
|
384
|
+
var ZanzoClient = class {
|
|
385
|
+
permissions;
|
|
386
|
+
snapshotCache = null;
|
|
387
|
+
/**
|
|
388
|
+
* Initializes the client with a strictly flat JSON representation of permissions.
|
|
389
|
+
* The input is deep-copied internally to prevent Prototype Pollution
|
|
390
|
+
* or external mutation attacks.
|
|
391
|
+
*
|
|
392
|
+
* @param compiledPermissions The Record<ResourceID, string[]> derived from `createZanzoSnapshot`
|
|
393
|
+
*/
|
|
394
|
+
constructor(compiledPermissions) {
|
|
395
|
+
this.permissions = new Map(
|
|
396
|
+
Object.entries(compiledPermissions).map(([key, actions]) => [
|
|
397
|
+
key,
|
|
398
|
+
new Set(Array.isArray(actions) ? actions : [])
|
|
399
|
+
])
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* True O(1) constant time evaluation of permissions via Set.has().
|
|
404
|
+
*
|
|
405
|
+
* @param action The specific action to evaluate
|
|
406
|
+
* @param resource The target resource entity identifier
|
|
407
|
+
* @returns boolean True if authorized, False otherwise
|
|
408
|
+
*/
|
|
409
|
+
can(action, resource) {
|
|
410
|
+
const allowedActions = this.permissions.get(resource);
|
|
411
|
+
if (!allowedActions) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
return allowedActions.has(action);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Returns the compiled snapshot state as a plain JSON object.
|
|
418
|
+
* Result is cached after first call to avoid re-serialization.
|
|
419
|
+
* Useful for persisting it locally or dumping to Redux/Vuex inside Client apps.
|
|
420
|
+
*/
|
|
421
|
+
getSnapshot() {
|
|
422
|
+
if (this.snapshotCache) {
|
|
423
|
+
return this.snapshotCache;
|
|
424
|
+
}
|
|
425
|
+
const result = /* @__PURE__ */ Object.create(null);
|
|
426
|
+
for (const [key, actions] of this.permissions) {
|
|
427
|
+
result[key] = [...actions];
|
|
428
|
+
}
|
|
429
|
+
this.snapshotCache = result;
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// src/expander/walk.ts
|
|
435
|
+
async function _walkExpansionGraph(schema, initialTuple, fetchChildren, maxSize) {
|
|
436
|
+
const results = [];
|
|
437
|
+
const processedRelations = /* @__PURE__ */ new Set();
|
|
438
|
+
const queue = [initialTuple];
|
|
439
|
+
let cursor = 0;
|
|
440
|
+
while (cursor < queue.length) {
|
|
441
|
+
if (results.length > maxSize) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`[Zanzo] Security Exception: Tuple expansion exceeded maximum size of ${maxSize}. Possible cycle in schema or data. Configure maxExpansionSize/maxCollapseSize to increase the limit.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const currentTuple = queue[cursor++];
|
|
447
|
+
let objectType;
|
|
448
|
+
try {
|
|
449
|
+
objectType = parseEntityRef(currentTuple.object).type;
|
|
450
|
+
} catch {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
for (const definition of Object.values(schema)) {
|
|
454
|
+
if (!definition.relations || !definition.permissions) continue;
|
|
455
|
+
const matchingRelations = [];
|
|
456
|
+
for (const [relName, relTarget] of Object.entries(definition.relations)) {
|
|
457
|
+
if (relTarget === objectType) {
|
|
458
|
+
matchingRelations.push(relName);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (matchingRelations.length === 0) continue;
|
|
462
|
+
for (const paths of Object.values(definition.permissions)) {
|
|
463
|
+
if (!Array.isArray(paths)) continue;
|
|
464
|
+
for (const path of paths) {
|
|
465
|
+
if (typeof path !== "string") continue;
|
|
466
|
+
const parts = path.split(RELATION_PATH_SEPARATOR);
|
|
467
|
+
if (parts.length >= 2) {
|
|
468
|
+
for (const relName of matchingRelations) {
|
|
469
|
+
if (parts[0] === relName && parts.slice(1).join(RELATION_PATH_SEPARATOR) === currentTuple.relation) {
|
|
470
|
+
const derivedRelation = `${relName}${RELATION_PATH_SEPARATOR}${currentTuple.relation}`;
|
|
471
|
+
const trackingSignature = `${currentTuple.object}|${derivedRelation}`;
|
|
472
|
+
if (!processedRelations.has(trackingSignature)) {
|
|
473
|
+
processedRelations.add(trackingSignature);
|
|
474
|
+
const children = await fetchChildren(currentTuple.object, relName);
|
|
475
|
+
if (Array.isArray(children)) {
|
|
476
|
+
for (const child of children) {
|
|
477
|
+
const result = {
|
|
478
|
+
subject: currentTuple.subject,
|
|
479
|
+
relation: derivedRelation,
|
|
480
|
+
object: child
|
|
481
|
+
};
|
|
482
|
+
results.push(result);
|
|
483
|
+
queue.push({
|
|
484
|
+
subject: currentTuple.subject,
|
|
485
|
+
relation: derivedRelation,
|
|
486
|
+
object: child
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return results;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/expander/index.ts
|
|
502
|
+
async function expandTuples(ctx) {
|
|
503
|
+
const { schema, newTuple, fetchChildren, maxExpansionSize = 500 } = ctx;
|
|
504
|
+
const walkResults = await _walkExpansionGraph(
|
|
505
|
+
schema,
|
|
506
|
+
newTuple,
|
|
507
|
+
fetchChildren,
|
|
508
|
+
maxExpansionSize
|
|
509
|
+
);
|
|
510
|
+
return walkResults.map((r) => ({
|
|
511
|
+
subject: r.subject,
|
|
512
|
+
relation: r.relation,
|
|
513
|
+
object: r.object
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/expander/collapse.ts
|
|
518
|
+
async function collapseTuples(ctx) {
|
|
519
|
+
const { schema, revokedTuple, fetchChildren, maxCollapseSize = 500 } = ctx;
|
|
520
|
+
const walkResults = await _walkExpansionGraph(
|
|
521
|
+
schema,
|
|
522
|
+
revokedTuple,
|
|
523
|
+
fetchChildren,
|
|
524
|
+
maxCollapseSize
|
|
525
|
+
);
|
|
526
|
+
return walkResults.map((r) => ({
|
|
527
|
+
subject: r.subject,
|
|
528
|
+
relation: r.relation,
|
|
529
|
+
object: r.object
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
exports.ENTITY_REF_SEPARATOR = ENTITY_REF_SEPARATOR;
|
|
534
|
+
exports.RELATION_PATH_SEPARATOR = RELATION_PATH_SEPARATOR;
|
|
535
|
+
exports.ZanzoBuilder = ZanzoBuilder;
|
|
536
|
+
exports.ZanzoClient = ZanzoClient;
|
|
537
|
+
exports.ZanzoEngine = ZanzoEngine;
|
|
538
|
+
exports.collapseTuples = collapseTuples;
|
|
539
|
+
exports.createZanzoSnapshot = createZanzoSnapshot;
|
|
540
|
+
exports.expandTuples = expandTuples;
|
|
541
|
+
exports.mergeSchemas = mergeSchemas;
|
|
542
|
+
exports.parseEntityRef = parseEntityRef;
|
|
543
|
+
exports.ref = ref;
|
|
544
|
+
exports.serializeEntityRef = serializeEntityRef;
|
|
545
|
+
//# sourceMappingURL=index.cjs.map
|
|
546
|
+
//# sourceMappingURL=index.cjs.map
|