@kysera/rls 0.8.4 → 0.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kysera/rls",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Row-Level Security plugin for Kysely - declarative policies, query transformation, native RLS support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,9 +21,9 @@
21
21
  ],
22
22
  "peerDependencies": {
23
23
  "kysely": ">=0.28.8",
24
- "zod": ">=3.0.0",
25
- "@kysera/repository": "0.8.4",
26
- "@kysera/executor": "0.8.4"
24
+ "zod": "^4.3.6",
25
+ "@kysera/executor": "0.8.6",
26
+ "@kysera/repository": "0.8.6"
27
27
  },
28
28
  "peerDependenciesMeta": {
29
29
  "@kysera/executor": {
@@ -33,11 +33,11 @@
33
33
  "optional": true
34
34
  },
35
35
  "zod": {
36
- "optional": false
36
+ "optional": true
37
37
  }
38
38
  },
39
39
  "dependencies": {
40
- "@kysera/core": "0.8.4"
40
+ "@kysera/core": "0.8.6"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/better-sqlite3": "^7.6.13",
@@ -51,9 +51,9 @@
51
51
  "tsup": "^8.5.1",
52
52
  "typescript": "^5.9.3",
53
53
  "vitest": "^4.0.16",
54
- "zod": "^3.24.2",
55
- "@kysera/repository": "0.8.4",
56
- "@kysera/executor": "0.8.4"
54
+ "zod": "^4.3.6",
55
+ "@kysera/executor": "0.8.6",
56
+ "@kysera/repository": "0.8.6"
57
57
  },
58
58
  "keywords": [
59
59
  "kysely",
package/src/plugin.ts CHANGED
@@ -429,7 +429,12 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
429
429
  },
430
430
 
431
431
  /**
432
- * Wrapped update with RLS check
432
+ * Wrapped update with RLS check.
433
+ *
434
+ * WARNING (TOCTOU): The existing row is fetched before the policy check.
435
+ * In concurrent environments, the row could be modified between fetch and
436
+ * check. For safety, call this method within a transaction. The underlying
437
+ * MutationGuard.checkUpdate documents this in detail.
433
438
  */
434
439
  async update(id: unknown, data: unknown): Promise<unknown> {
435
440
  if (!originalUpdate) {
@@ -499,7 +504,12 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
499
504
  },
500
505
 
501
506
  /**
502
- * Wrapped delete with RLS check
507
+ * Wrapped delete with RLS check.
508
+ *
509
+ * WARNING (TOCTOU): The existing row is fetched before the policy check.
510
+ * In concurrent environments, the row could be modified between fetch and
511
+ * check. For safety, call this method within a transaction. The underlying
512
+ * MutationGuard.checkDelete documents this in detail.
503
513
  */
504
514
  async delete(id: unknown): Promise<unknown> {
505
515
  if (!originalDelete) {
@@ -39,18 +39,35 @@ export class MutationGuard<DB = unknown> {
39
39
  }
40
40
 
41
41
  /**
42
- * Check if UPDATE operation is allowed
42
+ * Check if an UPDATE operation is allowed by RLS policies.
43
43
  *
44
- * @param table - Table name
45
- * @param existingRow - Current row data
46
- * @param data - Data being updated
47
- * @throws RLSPolicyViolation if access is denied
44
+ * IMPORTANT: To prevent TOCTOU race conditions, always call this method
45
+ * within the same transaction as the actual update operation. The existingRow
46
+ * should be fetched with SELECT FOR UPDATE within the transaction.
47
+ *
48
+ * Without proper transaction isolation, the existingRow could be modified by
49
+ * a concurrent operation between the time it was fetched and the time this
50
+ * check runs, leading to policy decisions based on stale data.
51
+ *
52
+ * @param table - Target table name
53
+ * @param existingRow - Current row data (should be fetched within same transaction)
54
+ * @param data - New data being applied
55
+ * @throws RLSPolicyViolation if the operation is not allowed
48
56
  *
49
57
  * @example
50
58
  * ```typescript
51
- * const guard = new MutationGuard(registry);
52
- * const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
53
- * await guard.checkUpdate('posts', existingPost, { title: 'Updated' });
59
+ * // RECOMMENDED: Use within a transaction with row locking
60
+ * await db.transaction().execute(async (trx) => {
61
+ * const existingPost = await trx
62
+ * .selectFrom('posts')
63
+ * .where('id', '=', 1)
64
+ * .forUpdate()
65
+ * .selectAll()
66
+ * .executeTakeFirst();
67
+ *
68
+ * await guard.checkUpdate('posts', existingPost, { title: 'Updated' });
69
+ * await trx.updateTable('posts').set({ title: 'Updated' }).where('id', '=', 1).execute();
70
+ * });
54
71
  * ```
55
72
  */
56
73
  async checkUpdate(
@@ -62,17 +79,35 @@ export class MutationGuard<DB = unknown> {
62
79
  }
63
80
 
64
81
  /**
65
- * Check if DELETE operation is allowed
82
+ * Check if a DELETE operation is allowed by RLS policies.
66
83
  *
67
- * @param table - Table name
68
- * @param existingRow - Row to be deleted
69
- * @throws RLSPolicyViolation if access is denied
84
+ * IMPORTANT: To prevent TOCTOU race conditions, always call this method
85
+ * within the same transaction as the actual delete operation. The existingRow
86
+ * should be fetched with SELECT FOR UPDATE within the transaction.
87
+ *
88
+ * Without proper transaction isolation, the existingRow could be modified by
89
+ * a concurrent operation between the time it was fetched and the time this
90
+ * check runs, leading to policy decisions based on stale data (e.g., a row's
91
+ * ownership could change, allowing an unauthorized delete).
92
+ *
93
+ * @param table - Target table name
94
+ * @param existingRow - Current row data (should be fetched within same transaction)
95
+ * @throws RLSPolicyViolation if the operation is not allowed
70
96
  *
71
97
  * @example
72
98
  * ```typescript
73
- * const guard = new MutationGuard(registry);
74
- * const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
75
- * await guard.checkDelete('posts', existingPost);
99
+ * // RECOMMENDED: Use within a transaction with row locking
100
+ * await db.transaction().execute(async (trx) => {
101
+ * const existingPost = await trx
102
+ * .selectFrom('posts')
103
+ * .where('id', '=', 1)
104
+ * .forUpdate()
105
+ * .selectAll()
106
+ * .executeTakeFirst();
107
+ *
108
+ * await guard.checkDelete('posts', existingPost);
109
+ * await trx.deleteFrom('posts').where('id', '=', 1).execute();
110
+ * });
76
111
  * ```
77
112
  */
78
113
  async checkDelete(table: string, existingRow: Record<string, unknown>): Promise<void> {