@zanzojs/drizzle 0.2.0 → 0.3.2

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 CHANGED
@@ -9,11 +9,15 @@ The official Drizzle ORM adapter for ZanzoJS.
9
9
 
10
10
  `@zanzojs/drizzle` serves two distinct purposes:
11
11
 
12
- **1. Write-time tuple materialization** (`expandTuples` / `collapseTuples`)
12
+ **1. Write-time tuple materialization** (`materializeDerivedTuples` / `removeDerivedTuples`)
13
13
  When you grant or revoke access via nested permission paths (e.g. `folder.admin`), you must pre-materialize the derived tuples in the database. This is what makes read-time evaluation fast.
14
14
 
15
15
  **2. SQL-filtered queries for large datasets**
16
16
  When you need to fetch a filtered list of resources (e.g. "all documents this user can read") and the dataset is too large to load entirely into memory, the adapter generates parameterized `EXISTS` subqueries that push the permission filter directly to the database.
17
+
18
+ > [!TIP]
19
+ > **Performance Optimized**: As of v0.3.0, the adapter automatically groups multiple permission paths into a single `EXISTS` subquery using an `IN` clause, providing significant performance gains for complex schemas.
20
+
17
21
  ```typescript
18
22
  // Without adapter — loads everything into memory and filters (inefficient for large datasets)
19
23
  const allDocs = await db.select().from(documents);
@@ -29,7 +33,7 @@ const myDocs = await db.select().from(documents)
29
33
  ## Installation
30
34
 
31
35
  ```bash
32
- pnpm add @zanzojs/core @zanzojs/drizzle drizzle-orm
36
+ pnpm add @zanzojs/core@latest @zanzojs/drizzle@latest drizzle-orm
33
37
  ```
34
38
 
35
39
  ## Setup
@@ -66,13 +70,57 @@ async function getReadableDocuments(userId: string) {
66
70
  }
67
71
  ```
68
72
 
73
+ ## Cloudflare D1 & Edge Runtime
74
+
75
+ Zanzo is fully compatible with Cloudflare Pages and D1. Since the D1 driver for Drizzle is asynchronous, ensuring your adapter is correctly configured is key.
76
+
77
+ ### 1. Configuration for D1
78
+ When using Cloudflare D1, always set the `dialect` to `'sqlite'`.
79
+
80
+ ```typescript
81
+ import { createZanzoAdapter } from '@zanzojs/drizzle';
82
+ import { engine } from './zanzo.config';
83
+ import { zanzoTuples } from './schema';
84
+
85
+ // The adapter is synchronous (generates SQL filters),
86
+ // but D1 execution is always asynchronous.
87
+ export const withPermissions = createZanzoAdapter(engine, zanzoTuples, {
88
+ dialect: 'sqlite'
89
+ });
90
+ ```
91
+
92
+ ### 2. Usage in Next.js (middleware/layout)
93
+ When using `@cloudflare/next-on-pages`, you access the D1 binding via the Request Context.
94
+
95
+ ```typescript
96
+ import { getRequestContext } from '@cloudflare/next-on-pages';
97
+ import { drizzle } from 'drizzle-orm/d1';
98
+
99
+ export const runtime = 'edge';
100
+
101
+ export default async function Page() {
102
+ const { env } = getRequestContext();
103
+ const db = drizzle(env.DB);
104
+
105
+ const myDocs = await db.select()
106
+ .from(documents)
107
+ .where(withPermissions('User:alice', 'read', 'Document', documents.id));
108
+
109
+ return <pre>{JSON.stringify(myDocs)}</pre>;
110
+ }
111
+ ```
112
+
113
+ ### 3. Limitations & Differences
114
+ - **Async-only**: D1 does not support synchronous queries. While `withPermissions` returns a synchronous `SQL` object, the final `.where()` call must be awaited as part of the Drizzle query.
115
+ - **AST Complexity**: The Edge Runtime has strict CPU and memory limits. Use `zanzo check` in your CI/CD to ensure your schema doesn't generate overly complex ASTs (standard limit: 100 conditional branches).
116
+
69
117
  ## Write Operations
70
118
 
71
- ### Granting access with expandTuples
119
+ ### Granting access with materializeDerivedTuples
72
120
 
73
- When assigning a role that involves nested permission paths, use `expandTuples` to materialize all derived tuples atomically.
121
+ When assigning a role that involves nested permission paths, use `materializeDerivedTuples` to materialize all derived tuples atomically.
74
122
  ```typescript
75
- import { expandTuples } from '@zanzojs/core';
123
+ import { materializeDerivedTuples } from '@zanzojs/core';
76
124
 
77
125
  async function grantAccess(userId: string, relation: string, objectId: string) {
78
126
  const baseTuple = {
@@ -81,7 +129,7 @@ async function grantAccess(userId: string, relation: string, objectId: string) {
81
129
  object: objectId,
82
130
  };
83
131
 
84
- const derived = await expandTuples({
132
+ const derived = await materializeDerivedTuples({
85
133
  schema: engine.getSchema(),
86
134
  newTuple: baseTuple,
87
135
  fetchChildren: async (parentObject, relation) => {
@@ -99,11 +147,11 @@ async function grantAccess(userId: string, relation: string, objectId: string) {
99
147
  }
100
148
  ```
101
149
 
102
- ### Revoking access with collapseTuples
150
+ ### Revoking access with removeDerivedTuples
103
151
 
104
- `collapseTuples` is the symmetric inverse of `expandTuples`. It identifies all derived tuples to delete.
152
+ `removeDerivedTuples` is the symmetric inverse of `materializeDerivedTuples`. It identifies all derived tuples to delete.
105
153
  ```typescript
106
- import { collapseTuples } from '@zanzojs/core';
154
+ import { removeDerivedTuples } from '@zanzojs/core';
107
155
 
108
156
  async function revokeAccess(userId: string, relation: string, objectId: string) {
109
157
  const baseTuple = {
@@ -112,7 +160,7 @@ async function revokeAccess(userId: string, relation: string, objectId: string)
112
160
  object: objectId,
113
161
  };
114
162
 
115
- const derived = await collapseTuples({
163
+ const derived = await removeDerivedTuples({
116
164
  schema: engine.getSchema(),
117
165
  revokedTuple: baseTuple,
118
166
  fetchChildren: async (parentObject, relation) => {
@@ -136,7 +184,7 @@ async function revokeAccess(userId: string, relation: string, objectId: string)
136
184
  }
137
185
  ```
138
186
 
139
- > **expandTuples and collapseTuples are symmetric.** If `expandTuples` derived a tuple, `collapseTuples` will identify it for deletion. This guarantees no orphaned tuples.
187
+ > **materializeDerivedTuples and removeDerivedTuples are symmetric.** If `materializeDerivedTuples` derived a tuple, `removeDerivedTuples` will identify it for deletion. This guarantees no orphaned tuples.
140
188
 
141
189
  ## Documentation
142
190
  For full architecture details, see the [ZanzoJS Monorepo](https://github.com/GonzaloJeria/zanzo).
package/dist/index.cjs CHANGED
@@ -26,37 +26,63 @@ module.exports = __toCommonJS(index_exports);
26
26
  var import_drizzle_orm = require("drizzle-orm");
27
27
  var import_core = require("@zanzojs/core");
28
28
  function createZanzoAdapter(engine, tupleTable, options) {
29
- const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
29
+ const isDev = typeof process !== "undefined" && !!process.env && process.env.NODE_ENV === "development";
30
30
  const shouldWarn = options?.warnOnNestedConditions ?? isDev;
31
+ const isDebug = options?.debug ?? false;
32
+ const dialect = options?.dialect ?? "postgres";
33
+ const astCache = /* @__PURE__ */ new Map();
31
34
  return function withPermissions(actor, action, resourceType, resourceIdColumn) {
32
- const ast = engine.buildDatabaseQuery(actor, action, resourceType);
35
+ const cacheKey = `${action}:${resourceType}`;
36
+ let ast = astCache.get(cacheKey);
37
+ if (ast === void 0) {
38
+ ast = engine.buildDatabaseQuery(actor, action, resourceType);
39
+ astCache.set(cacheKey, ast);
40
+ }
41
+ if (isDebug) {
42
+ console.debug(`[Zanzo Debug] Action: ${action}, Resource: ${resourceType}`);
43
+ console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));
44
+ }
33
45
  if (ast && ast.conditions.length > 100) {
34
- throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);
46
+ throw new import_core.ZanzoError(import_core.ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);
35
47
  }
36
- if (!ast) {
48
+ if (!ast || ast.conditions.length === 0) {
37
49
  return import_drizzle_orm.sql`1 = 0`;
38
50
  }
39
- const parseCondition = (cond) => {
40
- const objectString = import_drizzle_orm.sql`${resourceType} || '${import_drizzle_orm.sql.raw(import_core.ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;
51
+ const objectString = dialect === "sqlite" ? import_drizzle_orm.sql`${resourceType} || ${import_core.ENTITY_REF_SEPARATOR} || ${resourceIdColumn}` : import_drizzle_orm.sql`CONCAT(${resourceType}, ${import_core.ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;
52
+ const relationsBySubject = /* @__PURE__ */ new Map();
53
+ for (const cond of ast.conditions) {
54
+ const fullRelation = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(import_core.RELATION_PATH_SEPARATOR) : cond.relation;
41
55
  if (cond.type === "nested" && shouldWarn) {
42
- console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(import_core.RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
56
+ console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
43
57
  }
44
- const relationString = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(import_core.RELATION_PATH_SEPARATOR) : cond.relation;
45
- return import_drizzle_orm.sql`EXISTS (
46
- SELECT 1 FROM ${tupleTable}
47
- WHERE ${tupleTable.object} = ${objectString}
48
- AND ${tupleTable.relation} = ${relationString}
49
- AND ${tupleTable.subject} = ${cond.targetSubject}
50
- )`;
51
- };
52
- const parsedConditions = ast.conditions.map(parseCondition).filter((c) => c !== void 0);
53
- if (parsedConditions.length === 0) {
54
- return import_drizzle_orm.sql`1 = 0`;
58
+ let relations = relationsBySubject.get(actor);
59
+ if (!relations) {
60
+ relations = /* @__PURE__ */ new Set();
61
+ relationsBySubject.set(actor, relations);
62
+ }
63
+ relations.add(fullRelation);
64
+ }
65
+ const sqlConditions = [];
66
+ for (const [subject, relations] of relationsBySubject.entries()) {
67
+ const relationArray = Array.from(relations);
68
+ const condition = relationArray.length === 1 ? import_drizzle_orm.sql`EXISTS (
69
+ SELECT 1 FROM ${tupleTable}
70
+ WHERE ${tupleTable.object} = ${objectString}
71
+ AND ${tupleTable.relation} = ${relationArray[0]}
72
+ AND ${tupleTable.subject} = ${subject}
73
+ )` : import_drizzle_orm.sql`EXISTS (
74
+ SELECT 1 FROM ${tupleTable}
75
+ WHERE ${tupleTable.object} = ${objectString}
76
+ AND ${tupleTable.relation} IN (${import_drizzle_orm.sql.join(relationArray.map((r) => import_drizzle_orm.sql`${r}`), import_drizzle_orm.sql`, `)})
77
+ AND ${tupleTable.subject} = ${subject}
78
+ )`;
79
+ sqlConditions.push(condition);
55
80
  }
56
- if (ast.operator === "AND") {
57
- return (0, import_drizzle_orm.and)(...parsedConditions);
81
+ const finalFilter = ast.operator === "AND" ? (0, import_drizzle_orm.and)(...sqlConditions) : (0, import_drizzle_orm.or)(...sqlConditions);
82
+ if (isDebug) {
83
+ console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);
58
84
  }
59
- return (0, import_drizzle_orm.or)(...parsedConditions);
85
+ return finalFilter;
60
86
  };
61
87
  }
62
88
  // Annotate the CommonJS export names for ESM import in node:
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use expandTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @param actor The Subject identifier validating access (e.g \"User:1\")\n * @param action The protected action (e.g \"read\")\n * @param resourceType The target Domain scope (e.g \"Invoice\")\n * @param resourceIdColumn The specific Drizzle column representing the object's ID in the business table (e.g `invoices.id`)\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Evaluate the underlying pure logical AST\n const ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion:\n // If a badly designed ReBAC schema generates a monstrous combinatorial AST tree, \n // it could exceed max SQL text limits resulting in DB crashes. Abort safely.\n if (ast && ast.conditions.length > 100) {\n throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);\n }\n\n if (!ast) {\n // Access totally denied\n return sql`1 = 0`; \n }\n\n const parseCondition = (cond: Condition): SQL<unknown> | undefined => {\n \n // In the Zanzibar Pattern, ALL conditions (direct or nested) ultimately result\n // in looking up pre-computed or dynamically queried tuples.\n // E.g for a direct target: SELECT 1 FROM tuples WHERE object = TYPE:ID AND relation = X AND subject = TARGET\n \n const objectString = sql`${resourceType} || '${sql.raw(ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;\n\n // In Zanzibar, nested conditions (e.g. org.admin) are evaluated using the \"Tuple Expansion\" pattern.\n // This means the user has asynchronously written materialized tuples into the database.\n // Therefore, both direct and nested queries are resolved identically via O(1) EXISTS lookups.\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n const relationString = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n return sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationString} \n AND ${tupleTable.subject} = ${cond.targetSubject}\n )`;\n };\n\n const parsedConditions = ast.conditions\n .map(parseCondition)\n .filter((c): c is SQL<unknown> => c !== undefined);\n\n if (parsedConditions.length === 0) {\n return sql`1 = 0`;\n }\n\n if (ast.operator === 'AND') {\n return and(...parsedConditions) as SQL<unknown>;\n }\n\n return or(...parsedConditions) as SQL<unknown>;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA6C;AAE7C,kBAA8D;AA+BvD,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,QAAQ,KAAK,aAAa;AAC1E,QAAM,aAAa,SAAS,0BAA0B;AAUtD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAGd,UAAM,MAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AAK/E,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,MAAM,oMAAoM;AAAA,IACtN;AAEA,QAAI,CAAC,KAAK;AAER,aAAO;AAAA,IACT;AAEA,UAAM,iBAAiB,CAAC,SAA8C;AAMpE,YAAM,eAAe,yBAAM,YAAY,QAAQ,uBAAI,IAAI,gCAAoB,CAAC,QAAQ,gBAAgB;AAKpG,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,mCAAuB,CAAC,0LAA0L;AAAA,MAC7T;AAEA,YAAM,iBAAiB,KAAK,SAAS,WACjC,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,mCAAuB,IACtE,KAAK;AAET,aAAO;AAAA,wBACW,UAAU;AAAA,gBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,gBACnC,WAAW,QAAQ,MAAM,cAAc;AAAA,gBACvC,WAAW,OAAO,MAAM,KAAK,aAAa;AAAA;AAAA,IAEtD;AAEA,UAAM,mBAAmB,IAAI,WAC1B,IAAI,cAAc,EAClB,OAAO,CAAC,MAAyB,MAAM,MAAS;AAEnD,QAAI,iBAAiB,WAAW,GAAG;AAChC,aAAO;AAAA,IACV;AAEA,QAAI,IAAI,aAAa,OAAO;AAC1B,iBAAO,wBAAI,GAAG,gBAAgB;AAAA,IAChC;AAEA,eAAO,uBAAG,GAAG,gBAAgB;AAAA,EAC/B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR, ZanzoError, ZanzoErrorCode } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use materializeDerivedTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n\n /**\n * If true, logs the generated AST and SQL conditions to the console for debugging purposes.\n * @default false\n */\n debug?: boolean;\n\n /**\n * The database dialect. Used to optimize string concatenation.\n * If not provided, it defaults to 'postgres' which uses standard CONCAT.\n * @default 'postgres'\n */\n dialect?: 'mysql' | 'postgres' | 'sqlite';\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @remarks\n * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been \n * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input \n * directly without routing through the engine first may bypass validation and produce \n * unexpected query behavior.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && !!process.env && process.env.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n const isDebug = options?.debug ?? false;\n const dialect = options?.dialect ?? 'postgres';\n\n // Performance Optimization: Cache structural ASTs per action+resourceType.\n // The cache stores the structural shape of the AST (relations, paths, operator)\n // which is static for a given action+resourceType combination.\n //\n // SECURITY (v0.3.0 fix): The `targetSubject` stored inside cached AST conditions\n // is IGNORED during SQL generation. The actual actor is always injected from the\n // current invocation's `actor` argument. This prevents cross-user data leakage\n // when the adapter instance is shared across requests in persistent servers\n // (Express, Fastify, etc.). See: architectural_review.md §CRÍTICO drizzle.\n const astCache = new Map<string, QueryAST | null>();\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @remarks\n * This function assumes all identifiers have been validated by the ZanzoEngine.\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Cache key is structural: action + resourceType (actor is NOT included).\n // The AST structure (which relations grant which actions) is immutable per schema.\n const cacheKey = `${action as string}:${resourceType as string}`;\n let ast = astCache.get(cacheKey);\n\n if (ast === undefined) {\n // Build the structural AST. The actor passed here is irrelevant for the cached\n // structure — only the relations and their types matter. The targetSubject baked\n // into the AST conditions will be ignored during SQL generation below.\n ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n astCache.set(cacheKey, ast);\n }\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Action: ${action as string}, Resource: ${resourceType as string}`);\n console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));\n }\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion\n if (ast && ast.conditions.length > 100) {\n throw new ZanzoError(ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);\n }\n\n if (!ast || ast.conditions.length === 0) {\n return sql`1 = 0`; \n }\n\n // Dialect-agnostic/Secure concatenation for the object identifier: \"ResourceType:ID\"\n // We avoid sql.raw to prevent injection.\n const objectString = dialect === 'sqlite'\n ? sql`${resourceType} || ${ENTITY_REF_SEPARATOR} || ${resourceIdColumn}`\n : sql`CONCAT(${resourceType}, ${ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;\n\n // OPTIMIZATION: In Zanzibar, most permissions share the same subject and object target.\n // We group all conditions that share the same targetSubject into a single EXISTS subquery using IN.\n const relationsBySubject = new Map<string, Set<string>>();\n\n for (const cond of ast.conditions) {\n // Logic for building the full relation name (e.g. \"workspace.viewer\")\n const fullRelation = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n // SECURITY FIX: Always use the `actor` from the current invocation, NEVER\n // `cond.targetSubject` from the cached AST. The cached AST may contain a\n // stale actor from a previous request. This is the core of the cross-user\n // data leakage fix — the actor is bound at SQL generation time, not at\n // AST construction time.\n let relations = relationsBySubject.get(actor);\n if (!relations) {\n relations = new Set();\n relationsBySubject.set(actor, relations);\n }\n relations.add(fullRelation);\n }\n\n const sqlConditions: SQL<unknown>[] = [];\n\n for (const [subject, relations] of relationsBySubject.entries()) {\n const relationArray = Array.from(relations);\n \n const condition = relationArray.length === 1\n ? sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationArray[0]} \n AND ${tupleTable.subject} = ${subject}\n )`\n : sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} IN (${sql.join(relationArray.map(r => sql`${r}`), sql`, `)}) \n AND ${tupleTable.subject} = ${subject}\n )`;\n \n sqlConditions.push(condition);\n }\n\n const finalFilter = (ast.operator === 'AND' ? and(...sqlConditions) : or(...sqlConditions)) as SQL<unknown>;\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);\n }\n\n return finalFilter;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA6C;AAE7C,kBAA0F;AAkDnF,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,CAAC,CAAC,QAAQ,OAAO,QAAQ,IAAI,aAAa;AAC1F,QAAM,aAAa,SAAS,0BAA0B;AACtD,QAAM,UAAU,SAAS,SAAS;AAClC,QAAM,UAAU,SAAS,WAAW;AAWpC,QAAM,WAAW,oBAAI,IAA6B;AAQlD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAId,UAAM,WAAW,GAAG,MAAgB,IAAI,YAAsB;AAC9D,QAAI,MAAM,SAAS,IAAI,QAAQ;AAE/B,QAAI,QAAQ,QAAW;AAIrB,YAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AACzE,eAAS,IAAI,UAAU,GAAG;AAAA,IAC5B;AAEA,QAAI,SAAS;AACX,cAAQ,MAAM,yBAAyB,MAAgB,eAAe,YAAsB,EAAE;AAC9F,cAAQ,MAAM,gCAAgC,KAAK,UAAU,KAAK,MAAM,CAAC,CAAC;AAAA,IAC5E;AAGA,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,uBAAW,2BAAe,cAAc,2GAA2G;AAAA,IAC/J;AAEA,QAAI,CAAC,OAAO,IAAI,WAAW,WAAW,GAAG;AACvC,aAAO;AAAA,IACT;AAIA,UAAM,eAAe,YAAY,WAC7B,yBAAM,YAAY,OAAO,gCAAoB,OAAO,gBAAgB,KACpE,gCAAa,YAAY,KAAK,gCAAoB,KAAK,gBAAgB;AAI3E,UAAM,qBAAqB,oBAAI,IAAyB;AAExD,eAAW,QAAQ,IAAI,YAAY;AAEjC,YAAM,eAAe,KAAK,SAAS,WAC/B,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,mCAAuB,IACtE,KAAK;AAET,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,YAAY,sMAAsM;AAAA,MAC9Q;AAOA,UAAI,YAAY,mBAAmB,IAAI,KAAK;AAC5C,UAAI,CAAC,WAAW;AACd,oBAAY,oBAAI,IAAI;AACpB,2BAAmB,IAAI,OAAO,SAAS;AAAA,MACzC;AACA,gBAAU,IAAI,YAAY;AAAA,IAC5B;AAEA,UAAM,gBAAgC,CAAC;AAEvC,eAAW,CAAC,SAAS,SAAS,KAAK,mBAAmB,QAAQ,GAAG;AAC/D,YAAM,gBAAgB,MAAM,KAAK,SAAS;AAE1C,YAAM,YAAY,cAAc,WAAW,IACvC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,MAAM,cAAc,CAAC,CAAC;AAAA,oBACzC,WAAW,OAAO,MAAM,OAAO;AAAA,eAEzC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,QAAQ,uBAAI,KAAK,cAAc,IAAI,OAAK,yBAAM,CAAC,EAAE,GAAG,0BAAO,CAAC;AAAA,oBAC/E,WAAW,OAAO,MAAM,OAAO;AAAA;AAG7C,oBAAc,KAAK,SAAS;AAAA,IAC9B;AAEA,UAAM,cAAe,IAAI,aAAa,YAAQ,wBAAI,GAAG,aAAa,QAAI,uBAAG,GAAG,aAAa;AAEzF,QAAI,SAAS;AACX,cAAQ,MAAM,2CAA2C;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AACF;","names":[]}
package/dist/index.d.cts CHANGED
@@ -13,16 +13,33 @@ interface ZanzoTupleTable {
13
13
  interface ZanzoAdapterOptions {
14
14
  /**
15
15
  * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,
16
- * reminding you to use expandTuples() when writing this relationship.
16
+ * reminding you to use materializeDerivedTuples() when writing this relationship.
17
17
  *
18
18
  * @default true in NODE_ENV=development, false in production
19
19
  */
20
20
  warnOnNestedConditions?: boolean;
21
+ /**
22
+ * If true, logs the generated AST and SQL conditions to the console for debugging purposes.
23
+ * @default false
24
+ */
25
+ debug?: boolean;
26
+ /**
27
+ * The database dialect. Used to optimize string concatenation.
28
+ * If not provided, it defaults to 'postgres' which uses standard CONCAT.
29
+ * @default 'postgres'
30
+ */
31
+ dialect?: 'mysql' | 'postgres' | 'sqlite';
21
32
  }
22
33
  /**
23
34
  * Creates a "Zero-Config" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.
24
35
  * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.
25
36
  *
37
+ * @remarks
38
+ * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been
39
+ * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input
40
+ * directly without routing through the engine first may bypass validation and produce
41
+ * unexpected query behavior.
42
+ *
26
43
  * @param engine The initialized ZanzoEngine instance
27
44
  * @param tupleTable The central Drizzle Table where all Relation Tuples are stored
28
45
  * @param options Optional configuration for the adapter
package/dist/index.d.ts CHANGED
@@ -13,16 +13,33 @@ interface ZanzoTupleTable {
13
13
  interface ZanzoAdapterOptions {
14
14
  /**
15
15
  * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,
16
- * reminding you to use expandTuples() when writing this relationship.
16
+ * reminding you to use materializeDerivedTuples() when writing this relationship.
17
17
  *
18
18
  * @default true in NODE_ENV=development, false in production
19
19
  */
20
20
  warnOnNestedConditions?: boolean;
21
+ /**
22
+ * If true, logs the generated AST and SQL conditions to the console for debugging purposes.
23
+ * @default false
24
+ */
25
+ debug?: boolean;
26
+ /**
27
+ * The database dialect. Used to optimize string concatenation.
28
+ * If not provided, it defaults to 'postgres' which uses standard CONCAT.
29
+ * @default 'postgres'
30
+ */
31
+ dialect?: 'mysql' | 'postgres' | 'sqlite';
21
32
  }
22
33
  /**
23
34
  * Creates a "Zero-Config" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.
24
35
  * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.
25
36
  *
37
+ * @remarks
38
+ * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been
39
+ * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input
40
+ * directly without routing through the engine first may bypass validation and produce
41
+ * unexpected query behavior.
42
+ *
26
43
  * @param engine The initialized ZanzoEngine instance
27
44
  * @param tupleTable The central Drizzle Table where all Relation Tuples are stored
28
45
  * @param options Optional configuration for the adapter
package/dist/index.js CHANGED
@@ -1,38 +1,64 @@
1
1
  // src/index.ts
2
2
  import { or, and, sql } from "drizzle-orm";
3
- import { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR } from "@zanzojs/core";
3
+ import { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR, ZanzoError, ZanzoErrorCode } from "@zanzojs/core";
4
4
  function createZanzoAdapter(engine, tupleTable, options) {
5
- const isDev = typeof process !== "undefined" && process.env?.NODE_ENV === "development";
5
+ const isDev = typeof process !== "undefined" && !!process.env && process.env.NODE_ENV === "development";
6
6
  const shouldWarn = options?.warnOnNestedConditions ?? isDev;
7
+ const isDebug = options?.debug ?? false;
8
+ const dialect = options?.dialect ?? "postgres";
9
+ const astCache = /* @__PURE__ */ new Map();
7
10
  return function withPermissions(actor, action, resourceType, resourceIdColumn) {
8
- const ast = engine.buildDatabaseQuery(actor, action, resourceType);
11
+ const cacheKey = `${action}:${resourceType}`;
12
+ let ast = astCache.get(cacheKey);
13
+ if (ast === void 0) {
14
+ ast = engine.buildDatabaseQuery(actor, action, resourceType);
15
+ astCache.set(cacheKey, ast);
16
+ }
17
+ if (isDebug) {
18
+ console.debug(`[Zanzo Debug] Action: ${action}, Resource: ${resourceType}`);
19
+ console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));
20
+ }
9
21
  if (ast && ast.conditions.length > 100) {
10
- throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);
22
+ throw new ZanzoError(ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);
11
23
  }
12
- if (!ast) {
24
+ if (!ast || ast.conditions.length === 0) {
13
25
  return sql`1 = 0`;
14
26
  }
15
- const parseCondition = (cond) => {
16
- const objectString = sql`${resourceType} || '${sql.raw(ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;
27
+ const objectString = dialect === "sqlite" ? sql`${resourceType} || ${ENTITY_REF_SEPARATOR} || ${resourceIdColumn}` : sql`CONCAT(${resourceType}, ${ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;
28
+ const relationsBySubject = /* @__PURE__ */ new Map();
29
+ for (const cond of ast.conditions) {
30
+ const fullRelation = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) : cond.relation;
17
31
  if (cond.type === "nested" && shouldWarn) {
18
- console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
32
+ console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);
19
33
  }
20
- const relationString = cond.type === "nested" ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) : cond.relation;
21
- return sql`EXISTS (
22
- SELECT 1 FROM ${tupleTable}
23
- WHERE ${tupleTable.object} = ${objectString}
24
- AND ${tupleTable.relation} = ${relationString}
25
- AND ${tupleTable.subject} = ${cond.targetSubject}
26
- )`;
27
- };
28
- const parsedConditions = ast.conditions.map(parseCondition).filter((c) => c !== void 0);
29
- if (parsedConditions.length === 0) {
30
- return sql`1 = 0`;
34
+ let relations = relationsBySubject.get(actor);
35
+ if (!relations) {
36
+ relations = /* @__PURE__ */ new Set();
37
+ relationsBySubject.set(actor, relations);
38
+ }
39
+ relations.add(fullRelation);
40
+ }
41
+ const sqlConditions = [];
42
+ for (const [subject, relations] of relationsBySubject.entries()) {
43
+ const relationArray = Array.from(relations);
44
+ const condition = relationArray.length === 1 ? sql`EXISTS (
45
+ SELECT 1 FROM ${tupleTable}
46
+ WHERE ${tupleTable.object} = ${objectString}
47
+ AND ${tupleTable.relation} = ${relationArray[0]}
48
+ AND ${tupleTable.subject} = ${subject}
49
+ )` : sql`EXISTS (
50
+ SELECT 1 FROM ${tupleTable}
51
+ WHERE ${tupleTable.object} = ${objectString}
52
+ AND ${tupleTable.relation} IN (${sql.join(relationArray.map((r) => sql`${r}`), sql`, `)})
53
+ AND ${tupleTable.subject} = ${subject}
54
+ )`;
55
+ sqlConditions.push(condition);
31
56
  }
32
- if (ast.operator === "AND") {
33
- return and(...parsedConditions);
57
+ const finalFilter = ast.operator === "AND" ? and(...sqlConditions) : or(...sqlConditions);
58
+ if (isDebug) {
59
+ console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);
34
60
  }
35
- return or(...parsedConditions);
61
+ return finalFilter;
36
62
  };
37
63
  }
38
64
  export {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use expandTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @param actor The Subject identifier validating access (e.g \"User:1\")\n * @param action The protected action (e.g \"read\")\n * @param resourceType The target Domain scope (e.g \"Invoice\")\n * @param resourceIdColumn The specific Drizzle column representing the object's ID in the business table (e.g `invoices.id`)\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Evaluate the underlying pure logical AST\n const ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion:\n // If a badly designed ReBAC schema generates a monstrous combinatorial AST tree, \n // it could exceed max SQL text limits resulting in DB crashes. Abort safely.\n if (ast && ast.conditions.length > 100) {\n throw new Error(`[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches. Please optimize your schema or rely on pre-computed tuples to avoid database exhaustion.`);\n }\n\n if (!ast) {\n // Access totally denied\n return sql`1 = 0`; \n }\n\n const parseCondition = (cond: Condition): SQL<unknown> | undefined => {\n \n // In the Zanzibar Pattern, ALL conditions (direct or nested) ultimately result\n // in looking up pre-computed or dynamically queried tuples.\n // E.g for a direct target: SELECT 1 FROM tuples WHERE object = TYPE:ID AND relation = X AND subject = TARGET\n \n const objectString = sql`${resourceType} || '${sql.raw(ENTITY_REF_SEPARATOR)}' || ${resourceIdColumn}`;\n\n // In Zanzibar, nested conditions (e.g. org.admin) are evaluated using the \"Tuple Expansion\" pattern.\n // This means the user has asynchronously written materialized tuples into the database.\n // Therefore, both direct and nested queries are resolved identically via O(1) EXISTS lookups.\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${[cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR)}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used expandTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n const relationString = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n return sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationString} \n AND ${tupleTable.subject} = ${cond.targetSubject}\n )`;\n };\n\n const parsedConditions = ast.conditions\n .map(parseCondition)\n .filter((c): c is SQL<unknown> => c !== undefined);\n\n if (parsedConditions.length === 0) {\n return sql`1 = 0`;\n }\n\n if (ast.operator === 'AND') {\n return and(...parsedConditions) as SQL<unknown>;\n }\n\n return or(...parsedConditions) as SQL<unknown>;\n };\n}\n"],"mappings":";AAAA,SAAS,IAAI,KAAU,WAAsB;AAE7C,SAAS,sBAAsB,+BAA+B;AA+BvD,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,QAAQ,KAAK,aAAa;AAC1E,QAAM,aAAa,SAAS,0BAA0B;AAUtD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAGd,UAAM,MAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AAK/E,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,MAAM,oMAAoM;AAAA,IACtN;AAEA,QAAI,CAAC,KAAK;AAER,aAAO;AAAA,IACT;AAEA,UAAM,iBAAiB,CAAC,SAA8C;AAMpE,YAAM,eAAe,MAAM,YAAY,QAAQ,IAAI,IAAI,oBAAoB,CAAC,QAAQ,gBAAgB;AAKpG,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,CAAC,0LAA0L;AAAA,MAC7T;AAEA,YAAM,iBAAiB,KAAK,SAAS,WACjC,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,IACtE,KAAK;AAET,aAAO;AAAA,wBACW,UAAU;AAAA,gBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,gBACnC,WAAW,QAAQ,MAAM,cAAc;AAAA,gBACvC,WAAW,OAAO,MAAM,KAAK,aAAa;AAAA;AAAA,IAEtD;AAEA,UAAM,mBAAmB,IAAI,WAC1B,IAAI,cAAc,EAClB,OAAO,CAAC,MAAyB,MAAM,MAAS;AAEnD,QAAI,iBAAiB,WAAW,GAAG;AAChC,aAAO;AAAA,IACV;AAEA,QAAI,IAAI,aAAa,OAAO;AAC1B,aAAO,IAAI,GAAG,gBAAgB;AAAA,IAChC;AAEA,WAAO,GAAG,GAAG,gBAAgB;AAAA,EAC/B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { or, and, SQL, sql, AnyColumn } from 'drizzle-orm';\nimport type { QueryAST, Condition, ZanzoEngine, SchemaData, ExtractSchemaResources, ExtractSchemaActions } from '@zanzojs/core';\nimport { ENTITY_REF_SEPARATOR, RELATION_PATH_SEPARATOR, ZanzoError, ZanzoErrorCode } from '@zanzojs/core';\n\n/**\n * Ensures the passed Drizzle Table conforms to the mandatory Zanzibar Universal Tuple Structure.\n */\nexport interface ZanzoTupleTable {\n object: AnyColumn; // String e.g. \"Invoice:123\"\n relation: AnyColumn; // String e.g. \"owner\"\n subject: AnyColumn; // String e.g. \"User:1\"\n [key: string]: any; // Allow extensions like IDs or context\n}\n\nexport interface ZanzoAdapterOptions {\n /**\n * Emits a console.warn when a nested permission path (e.g. 'org.admin') is detected,\n * reminding you to use materializeDerivedTuples() when writing this relationship.\n *\n * @default true in NODE_ENV=development, false in production\n */\n warnOnNestedConditions?: boolean;\n\n /**\n * If true, logs the generated AST and SQL conditions to the console for debugging purposes.\n * @default false\n */\n debug?: boolean;\n\n /**\n * The database dialect. Used to optimize string concatenation.\n * If not provided, it defaults to 'postgres' which uses standard CONCAT.\n * @default 'postgres'\n */\n dialect?: 'mysql' | 'postgres' | 'sqlite';\n}\n\n/**\n * Creates a \"Zero-Config\" Drizzle ORM permission adapter tailored for the Zanzibar Pattern.\n * Rather than mapping individual specific columns, this queries a Universal Tuple Table resolving access instantly.\n *\n * @remarks\n * **Validation Contract**: This adapter assumes all identifiers (actor, resource IDs) have been \n * validated by the ZanzoEngine before calling `withPermissions`. Passing raw user input \n * directly without routing through the engine first may bypass validation and produce \n * unexpected query behavior.\n *\n * @param engine The initialized ZanzoEngine instance\n * @param tupleTable The central Drizzle Table where all Relation Tuples are stored\n * @param options Optional configuration for the adapter\n * @returns A bounded `withPermissions` closure\n */\nexport function createZanzoAdapter<TSchema extends SchemaData, TTable extends ZanzoTupleTable>(\n engine: ZanzoEngine<TSchema>,\n tupleTable: TTable,\n options?: ZanzoAdapterOptions\n) {\n // Smart default: auto-enable warnings in development unless explicitly configured\n const isDev = typeof process !== 'undefined' && !!process.env && process.env.NODE_ENV === 'development';\n const shouldWarn = options?.warnOnNestedConditions ?? isDev;\n const isDebug = options?.debug ?? false;\n const dialect = options?.dialect ?? 'postgres';\n\n // Performance Optimization: Cache structural ASTs per action+resourceType.\n // The cache stores the structural shape of the AST (relations, paths, operator)\n // which is static for a given action+resourceType combination.\n //\n // SECURITY (v0.3.0 fix): The `targetSubject` stored inside cached AST conditions\n // is IGNORED during SQL generation. The actual actor is always injected from the\n // current invocation's `actor` argument. This prevents cross-user data leakage\n // when the adapter instance is shared across requests in persistent servers\n // (Express, Fastify, etc.). See: architectural_review.md §CRÍTICO drizzle.\n const astCache = new Map<string, QueryAST | null>();\n\n /**\n * Generates a Drizzle SQL AST (subquery strategy) resolving access against the Universal Tuple Table.\n *\n * @remarks\n * This function assumes all identifiers have been validated by the ZanzoEngine.\n */\n return function withPermissions<\n TResourceName extends Extract<ExtractSchemaResources<TSchema>, string>,\n TAction extends ExtractSchemaActions<TSchema, TResourceName>,\n >(\n actor: string,\n action: TAction,\n resourceType: TResourceName,\n resourceIdColumn: AnyColumn\n ): SQL<unknown> {\n \n // Cache key is structural: action + resourceType (actor is NOT included).\n // The AST structure (which relations grant which actions) is immutable per schema.\n const cacheKey = `${action as string}:${resourceType as string}`;\n let ast = astCache.get(cacheKey);\n\n if (ast === undefined) {\n // Build the structural AST. The actor passed here is irrelevant for the cached\n // structure — only the relations and their types matter. The targetSubject baked\n // into the AST conditions will be ignored during SQL generation below.\n ast = engine.buildDatabaseQuery(actor, action as any, resourceType as any);\n astCache.set(cacheKey, ast);\n }\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Action: ${action as string}, Resource: ${resourceType as string}`);\n console.debug(`[Zanzo Debug] Generated AST:`, JSON.stringify(ast, null, 2));\n }\n\n // Protection Against 'The List Problem' / Query Payload Exhaustion\n if (ast && ast.conditions.length > 100) {\n throw new ZanzoError(ZanzoErrorCode.AST_OVERFLOW, `[Zanzo] Security Exception: The resulting AST exceeds the maximum safe limit of 100 conditional branches.`);\n }\n\n if (!ast || ast.conditions.length === 0) {\n return sql`1 = 0`; \n }\n\n // Dialect-agnostic/Secure concatenation for the object identifier: \"ResourceType:ID\"\n // We avoid sql.raw to prevent injection.\n const objectString = dialect === 'sqlite'\n ? sql`${resourceType} || ${ENTITY_REF_SEPARATOR} || ${resourceIdColumn}`\n : sql`CONCAT(${resourceType}, ${ENTITY_REF_SEPARATOR}, ${resourceIdColumn})`;\n\n // OPTIMIZATION: In Zanzibar, most permissions share the same subject and object target.\n // We group all conditions that share the same targetSubject into a single EXISTS subquery using IN.\n const relationsBySubject = new Map<string, Set<string>>();\n\n for (const cond of ast.conditions) {\n // Logic for building the full relation name (e.g. \"workspace.viewer\")\n const fullRelation = cond.type === 'nested' \n ? [cond.relation, ...cond.nextRelationPath].join(RELATION_PATH_SEPARATOR) \n : cond.relation;\n\n if (cond.type === 'nested' && shouldWarn) {\n console.warn(`[Zanzo] Nested permission path detected: '${fullRelation}'. The SQL adapter resolves this via pre-materialized tuples. Ensure you used materializeDerivedTuples() when writing this relationship to the database. See: https://zanzo.dev/docs/tuple-expansion`);\n }\n\n // SECURITY FIX: Always use the `actor` from the current invocation, NEVER\n // `cond.targetSubject` from the cached AST. The cached AST may contain a\n // stale actor from a previous request. This is the core of the cross-user\n // data leakage fix — the actor is bound at SQL generation time, not at\n // AST construction time.\n let relations = relationsBySubject.get(actor);\n if (!relations) {\n relations = new Set();\n relationsBySubject.set(actor, relations);\n }\n relations.add(fullRelation);\n }\n\n const sqlConditions: SQL<unknown>[] = [];\n\n for (const [subject, relations] of relationsBySubject.entries()) {\n const relationArray = Array.from(relations);\n \n const condition = relationArray.length === 1\n ? sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} = ${relationArray[0]} \n AND ${tupleTable.subject} = ${subject}\n )`\n : sql`EXISTS (\n SELECT 1 FROM ${tupleTable} \n WHERE ${tupleTable.object} = ${objectString} \n AND ${tupleTable.relation} IN (${sql.join(relationArray.map(r => sql`${r}`), sql`, `)}) \n AND ${tupleTable.subject} = ${subject}\n )`;\n \n sqlConditions.push(condition);\n }\n\n const finalFilter = (ast.operator === 'AND' ? and(...sqlConditions) : or(...sqlConditions)) as SQL<unknown>;\n\n if (isDebug) {\n console.debug(`[Zanzo Debug] Final SQL Filter Generated.`);\n }\n\n return finalFilter;\n };\n}\n"],"mappings":";AAAA,SAAS,IAAI,KAAU,WAAsB;AAE7C,SAAS,sBAAsB,yBAAyB,YAAY,sBAAsB;AAkDnF,SAAS,mBACd,QACA,YACA,SACA;AAEA,QAAM,QAAQ,OAAO,YAAY,eAAe,CAAC,CAAC,QAAQ,OAAO,QAAQ,IAAI,aAAa;AAC1F,QAAM,aAAa,SAAS,0BAA0B;AACtD,QAAM,UAAU,SAAS,SAAS;AAClC,QAAM,UAAU,SAAS,WAAW;AAWpC,QAAM,WAAW,oBAAI,IAA6B;AAQlD,SAAO,SAAS,gBAId,OACA,QACA,cACA,kBACc;AAId,UAAM,WAAW,GAAG,MAAgB,IAAI,YAAsB;AAC9D,QAAI,MAAM,SAAS,IAAI,QAAQ;AAE/B,QAAI,QAAQ,QAAW;AAIrB,YAAM,OAAO,mBAAmB,OAAO,QAAe,YAAmB;AACzE,eAAS,IAAI,UAAU,GAAG;AAAA,IAC5B;AAEA,QAAI,SAAS;AACX,cAAQ,MAAM,yBAAyB,MAAgB,eAAe,YAAsB,EAAE;AAC9F,cAAQ,MAAM,gCAAgC,KAAK,UAAU,KAAK,MAAM,CAAC,CAAC;AAAA,IAC5E;AAGA,QAAI,OAAO,IAAI,WAAW,SAAS,KAAK;AACtC,YAAM,IAAI,WAAW,eAAe,cAAc,2GAA2G;AAAA,IAC/J;AAEA,QAAI,CAAC,OAAO,IAAI,WAAW,WAAW,GAAG;AACvC,aAAO;AAAA,IACT;AAIA,UAAM,eAAe,YAAY,WAC7B,MAAM,YAAY,OAAO,oBAAoB,OAAO,gBAAgB,KACpE,aAAa,YAAY,KAAK,oBAAoB,KAAK,gBAAgB;AAI3E,UAAM,qBAAqB,oBAAI,IAAyB;AAExD,eAAW,QAAQ,IAAI,YAAY;AAEjC,YAAM,eAAe,KAAK,SAAS,WAC/B,CAAC,KAAK,UAAU,GAAG,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,IACtE,KAAK;AAET,UAAI,KAAK,SAAS,YAAY,YAAY;AACxC,gBAAQ,KAAK,6CAA6C,YAAY,sMAAsM;AAAA,MAC9Q;AAOA,UAAI,YAAY,mBAAmB,IAAI,KAAK;AAC5C,UAAI,CAAC,WAAW;AACd,oBAAY,oBAAI,IAAI;AACpB,2BAAmB,IAAI,OAAO,SAAS;AAAA,MACzC;AACA,gBAAU,IAAI,YAAY;AAAA,IAC5B;AAEA,UAAM,gBAAgC,CAAC;AAEvC,eAAW,CAAC,SAAS,SAAS,KAAK,mBAAmB,QAAQ,GAAG;AAC/D,YAAM,gBAAgB,MAAM,KAAK,SAAS;AAE1C,YAAM,YAAY,cAAc,WAAW,IACvC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,MAAM,cAAc,CAAC,CAAC;AAAA,oBACzC,WAAW,OAAO,MAAM,OAAO;AAAA,eAEzC;AAAA,4BACkB,UAAU;AAAA,oBAClB,WAAW,MAAM,MAAM,YAAY;AAAA,oBACnC,WAAW,QAAQ,QAAQ,IAAI,KAAK,cAAc,IAAI,OAAK,MAAM,CAAC,EAAE,GAAG,OAAO,CAAC;AAAA,oBAC/E,WAAW,OAAO,MAAM,OAAO;AAAA;AAG7C,oBAAc,KAAK,SAAS;AAAA,IAC9B;AAEA,UAAM,cAAe,IAAI,aAAa,QAAQ,IAAI,GAAG,aAAa,IAAI,GAAG,GAAG,aAAa;AAEzF,QAAI,SAAS;AACX,cAAQ,MAAM,2CAA2C;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zanzojs/drizzle",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Drizzle ORM adapter for Zanzo ReBAC. Zero-config Zanzibar Tuple Pattern with parameterized SQL.",
5
5
  "keywords": [
6
6
  "@zanzojs/core",
@@ -21,9 +21,9 @@
21
21
  "type": "module",
22
22
  "exports": {
23
23
  ".": {
24
+ "types": "./dist/index.d.ts",
24
25
  "import": "./dist/index.js",
25
- "require": "./dist/index.cjs",
26
- "types": "./dist/index.d.ts"
26
+ "require": "./dist/index.cjs"
27
27
  }
28
28
  },
29
29
  "main": "./dist/index.cjs",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "peerDependencies": {
41
- "@zanzojs/core": "^0.2.0",
41
+ "@zanzojs/core": "^0.3.0",
42
42
  "drizzle-orm": ">=0.29.0"
43
43
  },
44
44
  "devDependencies": {
@@ -46,7 +46,7 @@
46
46
  "typescript": "^5.7.2",
47
47
  "tsup": "latest",
48
48
  "vitest": "latest",
49
- "@zanzojs/core": "0.2.0"
49
+ "@zanzojs/core": "0.3.1"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsup",