@kysera/rls 0.6.0 → 0.7.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @kysera/rls
2
2
 
3
- > **Declarative Row-Level Security for Kysera ORM** - Type-safe authorization policies with automatic query transformation and native PostgreSQL RLS support.
3
+ > **Row-Level Security Plugin for Kysera** - Declarative authorization policies with automatic query transformation and AsyncLocalStorage-based context management.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@kysera/rls.svg)](https://www.npmjs.com/package/@kysera/rls)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
@@ -8,18 +8,24 @@
8
8
 
9
9
  ---
10
10
 
11
- ## Features
11
+ ## Overview
12
12
 
13
- - **Declarative Policy DSL** - Define authorization rules with intuitive `allow`, `deny`, `filter`, and `validate` builders
14
- - **Automatic Query Transformation** - Transparently inject WHERE clauses and enforce policies without changing application code
15
- - **Type-Safe Context** - Full TypeScript inference for user context, row data, and mutations
16
- - **Multi-Tenant Isolation** - Built-in patterns for SaaS applications with tenant/organization separation
17
- - **Native PostgreSQL RLS** - Optional generation of database-level policies for defense-in-depth
18
- - **Role-Based Access Control** - Support for roles, permissions, and custom authorization attributes
19
- - **Kysera Plugin Architecture** - Seamless integration with the Kysera ORM ecosystem
20
- - **Zero Runtime Overhead** - Policies compiled at initialization, minimal performance impact
21
- - **Async Local Storage** - Request-scoped context management without prop drilling
22
- - **Audit Logging** - Optional decision logging for compliance and debugging
13
+ `@kysera/rls` provides Row-Level Security (RLS) for Kysera through a declarative policy system. It automatically filters queries and enforces authorization rules at the database access layer, ensuring data isolation and access control without manual filtering in your application code.
14
+
15
+ ### What is Row-Level Security?
16
+
17
+ RLS controls access to individual rows in database tables based on user context. Instead of manually adding WHERE clauses to every query, RLS policies are defined once and automatically applied to all database operations.
18
+
19
+ **Key Features:**
20
+
21
+ - **Declarative Policy DSL** - Define rules with `allow`, `deny`, `filter`, and `validate` builders
22
+ - **Automatic Query Transformation** - SELECT queries are filtered automatically via `interceptQuery`
23
+ - **Repository Extensions** - Wraps mutation methods via `extendRepository` for policy enforcement
24
+ - **Type-Safe Context** - Full TypeScript inference for user context and policies
25
+ - **Multi-Tenant Isolation** - Built-in patterns for SaaS tenant separation
26
+ - **Plugin Architecture** - Works with both Repository and DAL patterns via `@kysera/executor`
27
+ - **Zero Runtime Overhead** - Policies compiled at initialization
28
+ - **AsyncLocalStorage Context** - Request-scoped context without prop drilling
23
29
 
24
30
  ---
25
31
 
@@ -33,22 +39,21 @@ pnpm add @kysera/rls kysely
33
39
  yarn add @kysera/rls kysely
34
40
  ```
35
41
 
36
- **Peer Dependencies:**
37
- - `kysely` >= 0.28.8
38
- - `@kysera/repository` (workspace package)
39
- - `@kysera/core` (workspace package)
42
+ **Dependencies:**
43
+ - `kysely` >= 0.28.8 (peer dependency)
44
+ - `@kysera/core` - Core utilities (auto-installed)
45
+ - `@kysera/executor` - Plugin execution layer (auto-installed)
46
+ - `@kysera/repository` or `@kysera/dal` - For Repository or DAL patterns (install as needed)
40
47
 
41
48
  ---
42
49
 
43
50
  ## Quick Start
44
51
 
52
+ ### 1. Define RLS Schema
53
+
45
54
  ```typescript
46
- import { createORM } from '@kysera/repository';
47
- import { rlsPlugin, defineRLSSchema, allow, filter, rlsContext } from '@kysera/rls';
48
- import { Kysely, PostgresDialect } from 'kysely';
49
- import { Pool } from 'pg';
55
+ import { defineRLSSchema, filter, allow, validate } from '@kysera/rls';
50
56
 
51
- // Define your database schema
52
57
  interface Database {
53
58
  posts: {
54
59
  id: number;
@@ -57,15 +62,13 @@ interface Database {
57
62
  author_id: number;
58
63
  tenant_id: number;
59
64
  status: 'draft' | 'published';
60
- created_at: Date;
61
65
  };
62
66
  }
63
67
 
64
- // Define RLS policies
65
68
  const rlsSchema = defineRLSSchema<Database>({
66
69
  posts: {
67
70
  policies: [
68
- // Multi-tenant isolation - all users see only their tenant's data
71
+ // Multi-tenant isolation - filter by tenant
69
72
  filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
70
73
 
71
74
  // Authors can edit their own posts
@@ -73,28 +76,39 @@ const rlsSchema = defineRLSSchema<Database>({
73
76
  ctx.auth.userId === ctx.row.author_id
74
77
  ),
75
78
 
76
- // Only published posts are visible to regular users
77
- filter('read', ctx =>
78
- ctx.auth.roles.includes('admin') ? {} : { status: 'published' }
79
+ // Validate new posts belong to user's tenant
80
+ validate('create', ctx =>
81
+ ctx.data.tenant_id === ctx.auth.tenantId
79
82
  ),
80
83
  ],
81
84
  defaultDeny: true, // Require explicit allow
82
85
  },
83
86
  });
87
+ ```
88
+
89
+ ### 2. Create Plugin Container with RLS Plugin
90
+
91
+ ```typescript
92
+ import { createORM } from '@kysera/repository';
93
+ import { rlsPlugin, rlsContext } from '@kysera/rls';
94
+ import { Kysely, PostgresDialect } from 'kysely';
84
95
 
85
- // Create database connection
86
96
  const db = new Kysely<Database>({
87
- dialect: new PostgresDialect({ pool: new Pool({ /* config */ }) }),
97
+ dialect: new PostgresDialect({ /* config */ }),
88
98
  });
89
99
 
90
- // Create ORM with RLS plugin
91
100
  const orm = await createORM(db, [
92
101
  rlsPlugin({ schema: rlsSchema }),
93
102
  ]);
103
+ ```
104
+
105
+ ### 3. Execute Queries within RLS Context
94
106
 
95
- // Use within RLS context
107
+ ```typescript
108
+ import { rlsContext } from '@kysera/rls';
109
+
110
+ // In your request handler
96
111
  app.use(async (req, res, next) => {
97
- // Extract user from JWT/session
98
112
  const user = await authenticate(req);
99
113
 
100
114
  await rlsContext.runAsync(
@@ -108,8 +122,8 @@ app.use(async (req, res, next) => {
108
122
  timestamp: new Date(),
109
123
  },
110
124
  async () => {
111
- // All queries automatically filtered by tenant_id and policies
112
- const posts = await orm.posts.findAll(); // Only returns allowed posts
125
+ // All queries automatically filtered by policies
126
+ const posts = await orm.posts.findAll();
113
127
  res.json(posts);
114
128
  }
115
129
  );
@@ -118,49 +132,91 @@ app.use(async (req, res, next) => {
118
132
 
119
133
  ---
120
134
 
121
- ## Core Concepts
135
+ ## Plugin Architecture
122
136
 
123
- ### What is Row-Level Security?
137
+ ### Integration with @kysera/executor
138
+
139
+ The RLS plugin is built on `@kysera/executor`, which provides a unified plugin system that works with both Repository and DAL patterns.
140
+
141
+ **Plugin Metadata:**
124
142
 
125
- Row-Level Security (RLS) is an authorization mechanism that controls access to individual rows in database tables based on user context. Instead of granting or denying access to entire tables, RLS policies determine which rows a user can read, create, update, or delete.
143
+ ```typescript
144
+ {
145
+ name: '@kysera/rls',
146
+ version: '0.7.0',
147
+ priority: 50, // Runs after soft-delete (0), before audit (100)
148
+ dependencies: [],
149
+ }
150
+ ```
151
+
152
+ ### How It Works
153
+
154
+ The RLS plugin implements two key hooks from the `@kysera/executor` plugin system:
155
+
156
+ #### 1. `interceptQuery` - Query Filtering (SELECT)
157
+
158
+ The `interceptQuery` hook intercepts all query builder operations to apply RLS filtering:
126
159
 
127
- **Traditional Approach (Manual):**
128
160
  ```typescript
129
- // Manual filtering - error-prone, easy to forget
130
- const posts = await db
131
- .selectFrom('posts')
132
- .where('tenant_id', '=', req.user.tenantId) // Must remember every time!
133
- .where('status', '=', 'published')
134
- .selectAll()
135
- .execute();
161
+ // When you execute a SELECT query:
162
+ const posts = await orm.posts.findAll();
163
+
164
+ // The plugin interceptQuery hook:
165
+ // 1. Checks for RLS context (rlsContext.getContextOrNull())
166
+ // 2. Checks if system user (ctx.auth.isSystem) or bypass role
167
+ // 3. Applies filter policies as WHERE conditions via SelectTransformer
168
+ // 4. Returns filtered query builder
169
+ // 5. For mutations, marks metadata['__rlsRequired'] = true
136
170
  ```
137
171
 
138
- **RLS Approach (Automatic):**
172
+ **Key behavior:**
173
+ - SELECT operations: Policies are applied immediately as WHERE clauses
174
+ - INSERT/UPDATE/DELETE: Marked for validation (actual enforcement in `extendRepository`)
175
+ - Skip conditions: `skipTables`, `metadata['skipRLS']`, `requireContext`, system user, bypass roles
176
+
177
+ #### 2. `extendRepository` - Mutation Enforcement (CREATE/UPDATE/DELETE)
178
+
179
+ The `extendRepository` hook wraps repository mutation methods to enforce RLS policies:
180
+
139
181
  ```typescript
140
- // Automatic filtering - declarative, enforced everywhere
141
- const posts = await orm.posts.findAll(); // Automatically filtered by tenant + status
182
+ // When you call a mutation:
183
+ await repo.update(postId, { title: 'New Title' });
184
+
185
+ // The plugin extendRepository hook:
186
+ // 1. Wraps create/update/delete methods
187
+ // 2. Fetches existing row using getRawDb() (bypasses RLS filtering)
188
+ // 3. Evaluates allow/deny policies via MutationGuard
189
+ // 4. If allowed, calls original method
190
+ // 5. If denied, throws RLSPolicyViolation
191
+ // 6. Adds withoutRLS() and canAccess() utility methods
142
192
  ```
143
193
 
194
+ **Why use `getRawDb()`?** To prevent infinite recursion - we need to fetch the existing row without triggering RLS filtering. The `getRawDb()` function from `@kysera/executor` returns the original Kysely instance that bypasses all plugin hooks.
195
+
196
+ ---
197
+
198
+ ## Core Concepts
199
+
144
200
  ### Policy Types
145
201
 
146
- #### 1. **`allow`** - Grant Access
202
+ #### 1. `allow` - Grant Access
147
203
 
148
- Grants access when the condition evaluates to `true`. Multiple `allow` policies are combined with OR logic.
204
+ Grants access when condition evaluates to `true`. Multiple allow policies use OR logic.
149
205
 
150
206
  ```typescript
151
207
  // Allow users to read their own posts
152
208
  allow('read', ctx => ctx.auth.userId === ctx.row.author_id)
153
209
 
154
- // Allow admins to perform any operation
210
+ // Allow admins all operations
155
211
  allow('all', ctx => ctx.auth.roles.includes('admin'))
156
212
 
157
- // Allow updates only for draft posts
213
+ // Allow updates only for drafts
158
214
  allow('update', ctx => ctx.row.status === 'draft')
159
215
  ```
160
216
 
161
- #### 2. **`deny`** - Block Access
217
+ #### 2. `deny` - Block Access
162
218
 
163
- Blocks access when the condition evaluates to `true`. Deny policies **override** allow policies and are evaluated first.
219
+ Blocks access when condition evaluates to `true`. Deny policies **override** allow policies.
164
220
 
165
221
  ```typescript
166
222
  // Deny access to banned users
@@ -169,39 +225,41 @@ deny('all', ctx => ctx.auth.attributes?.banned === true)
169
225
  // Prevent deletion of published posts
170
226
  deny('delete', ctx => ctx.row.status === 'published')
171
227
 
172
- // Block all access to archived records
173
- deny('all', ctx => ctx.row.archived === true)
228
+ // Unconditional deny
229
+ deny('all') // Always deny
174
230
  ```
175
231
 
176
- #### 3. **`filter`** - Automatic Row Filtering
232
+ #### 3. `filter` - Automatic Filtering
177
233
 
178
- Adds WHERE conditions to SELECT queries automatically. Filter policies return an object with column-value pairs.
234
+ Adds WHERE conditions to SELECT queries automatically.
179
235
 
180
236
  ```typescript
181
- // Filter by tenant (multi-tenancy)
237
+ // Filter by tenant
182
238
  filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
183
239
 
184
- // Filter by organization with soft delete
185
- filter('read', ctx => ({
186
- organization_id: ctx.auth.organizationIds?.[0],
187
- deleted_at: null,
188
- }))
189
-
190
- // Dynamic filtering based on role
240
+ // Dynamic filtering
191
241
  filter('read', ctx =>
192
242
  ctx.auth.roles.includes('admin')
193
243
  ? {} // No filtering for admins
194
- : { status: 'published' } // Only published for others
244
+ : { status: 'published' }
195
245
  )
246
+
247
+ // Multiple conditions
248
+ filter('read', ctx => ({
249
+ organization_id: ctx.auth.organizationIds?.[0],
250
+ deleted_at: null,
251
+ }))
196
252
  ```
197
253
 
198
- #### 4. **`validate`** - Mutation Validation
254
+ #### 4. `validate` - Mutation Validation
199
255
 
200
- Validates data during CREATE and UPDATE operations before execution. Useful for business rules and data integrity.
256
+ Validates data during CREATE/UPDATE operations.
201
257
 
202
258
  ```typescript
203
- // Validate user can only create posts in their tenant
204
- validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
259
+ // Validate tenant ownership
260
+ validate('create', ctx =>
261
+ ctx.data.tenant_id === ctx.auth.tenantId
262
+ )
205
263
 
206
264
  // Validate status transitions
207
265
  validate('update', ctx => {
@@ -213,22 +271,17 @@ validate('update', ctx => {
213
271
  return !ctx.data.status ||
214
272
  validTransitions[ctx.row.status]?.includes(ctx.data.status);
215
273
  })
216
-
217
- // Validate email format
218
- validate('create', ctx => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ctx.data.email))
219
274
  ```
220
275
 
221
276
  ### Operations
222
277
 
223
- Policies can target specific database operations:
224
-
225
- | Operation | SQL Commands | Use Cases |
226
- |-----------|-------------|-----------|
227
- | `read` | SELECT | Control what data users can view |
228
- | `create` | INSERT | Control what data users can create |
229
- | `update` | UPDATE | Control what data users can modify |
230
- | `delete` | DELETE | Control what data users can remove |
231
- | `all` | All operations | Apply policy to all operations |
278
+ | Operation | SQL | Description |
279
+ |-----------|-----|-------------|
280
+ | `read` | SELECT | Control what users can view |
281
+ | `create` | INSERT | Control what users can create |
282
+ | `update` | UPDATE | Control what users can modify |
283
+ | `delete` | DELETE | Control what users can remove |
284
+ | `all` | All | Apply to all operations |
232
285
 
233
286
  ```typescript
234
287
  // Single operation
@@ -238,28 +291,26 @@ allow('read', ctx => /* ... */)
238
291
  allow(['read', 'update'], ctx => /* ... */)
239
292
 
240
293
  // All operations
241
- deny('all', ctx => ctx.auth.attributes?.suspended === true)
294
+ deny('all', ctx => ctx.auth.suspended)
242
295
  ```
243
296
 
244
297
  ### Policy Evaluation Order
245
298
 
246
- Policies are evaluated in a specific order to ensure security:
247
-
248
299
  ```
249
300
  1. Check bypass conditions (system user, bypass roles)
250
301
  → If bypassed, ALLOW and skip all policies
251
302
 
252
- 2. Evaluate DENY policies (sorted by priority, highest first)
303
+ 2. Evaluate DENY policies (priority: highest first)
253
304
  → If ANY deny matches, REJECT immediately
254
305
 
255
- 3. Evaluate ALLOW policies (sorted by priority, highest first)
306
+ 3. Evaluate ALLOW policies (priority: highest first)
256
307
  → If NO allow matches and defaultDeny=true, REJECT
257
308
 
258
- 4. Apply FILTER policies (for SELECT queries)
259
- → Combine all filter conditions with AND logic
309
+ 4. Apply FILTER policies (for SELECT)
310
+ → Combine all filters with AND
260
311
 
261
312
  5. Apply VALIDATE policies (for CREATE/UPDATE)
262
- → All validate conditions must pass
313
+ → All validations must pass
263
314
 
264
315
  6. Execute query
265
316
  ```
@@ -267,96 +318,26 @@ Policies are evaluated in a specific order to ensure security:
267
318
  **Priority System:**
268
319
  - Higher priority = evaluated first
269
320
  - Deny policies default to priority `100`
270
- - Allow/filter/validate policies default to priority `0`
271
- - Explicit priority overrides defaults
321
+ - Allow/filter/validate default to priority `0`
272
322
 
273
323
  ```typescript
274
324
  defineRLSSchema<Database>({
275
325
  posts: {
276
326
  policies: [
277
- // Evaluated FIRST (highest priority deny)
327
+ // Highest priority
278
328
  deny('all', ctx => ctx.auth.suspended, { priority: 200 }),
279
329
 
280
- // Evaluated SECOND (default deny priority)
281
- deny('delete', ctx => ctx.row.locked, { priority: 100 }),
282
-
283
- // Evaluated THIRD (custom priority)
284
- allow('read', ctx => ctx.auth.roles.includes('premium'), { priority: 50 }),
285
-
286
- // Evaluated LAST (default priority)
287
- allow('read', ctx => ctx.row.public, { priority: 0 }),
288
- ],
289
- },
290
- });
291
- ```
292
-
293
- ---
294
-
295
- ## Schema Definition
296
-
297
- ### `defineRLSSchema<DB>(schema)`
298
-
299
- Define RLS policies for your database tables with full type safety.
300
-
301
- ```typescript
302
- import { defineRLSSchema, allow, deny, filter, validate } from '@kysera/rls';
303
-
304
- const schema = defineRLSSchema<Database>({
305
- // Table name (must match your Kysely schema)
306
- posts: {
307
- // Array of policies to enforce
308
- policies: [
309
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
310
- allow('update', ctx => ctx.auth.userId === ctx.row.author_id),
311
- validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
312
- ],
313
-
314
- // Require explicit allow (default: true)
315
- defaultDeny: true,
316
-
317
- // Roles that bypass all policies (optional)
318
- skipFor: ['system', 'superadmin'],
319
- },
320
-
321
- comments: {
322
- policies: [
323
- // Policies for comments table
324
- ],
325
- },
326
- });
327
- ```
328
-
329
- **Options:**
330
-
331
- - **`policies`** (required) - Array of policy definitions
332
- - **`defaultDeny`** (default: `true`) - Deny access when no allow policies match
333
- - **`skipFor`** (optional) - Array of roles that bypass RLS for this table
330
+ // Default deny priority
331
+ deny('delete', ctx => ctx.row.locked),
334
332
 
335
- ### `mergeRLSSchemas(...schemas)`
336
-
337
- Combine multiple RLS schemas for modular policy management.
338
-
339
- ```typescript
340
- // Base tenant isolation
341
- const basePolicies = defineRLSSchema<Database>({
342
- posts: {
343
- policies: [
344
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
345
- ],
346
- },
347
- });
333
+ // Custom priority
334
+ allow('read', ctx => ctx.auth.premium, { priority: 50 }),
348
335
 
349
- // Admin overrides
350
- const adminPolicies = defineRLSSchema<Database>({
351
- posts: {
352
- policies: [
353
- allow('all', ctx => ctx.auth.roles.includes('admin')),
336
+ // Default priority
337
+ allow('read', ctx => ctx.row.public),
354
338
  ],
355
339
  },
356
340
  });
357
-
358
- // Merged schema applies both
359
- const schema = mergeRLSSchemas(basePolicies, adminPolicies);
360
341
  ```
361
342
 
362
343
  ---
@@ -365,8 +346,6 @@ const schema = mergeRLSSchemas(basePolicies, adminPolicies);
365
346
 
366
347
  ### `allow(operation, condition, options?)`
367
348
 
368
- Grant access when condition is true.
369
-
370
349
  ```typescript
371
350
  // Basic allow
372
351
  allow('read', ctx => ctx.auth.userId === ctx.row.user_id)
@@ -374,7 +353,7 @@ allow('read', ctx => ctx.auth.userId === ctx.row.user_id)
374
353
  // Multiple operations
375
354
  allow(['read', 'update'], ctx => ctx.row.owner_id === ctx.auth.userId)
376
355
 
377
- // All operations (admin bypass)
356
+ // All operations
378
357
  allow('all', ctx => ctx.auth.roles.includes('admin'))
379
358
 
380
359
  // With options
@@ -393,8 +372,6 @@ allow('update', async ctx => {
393
372
 
394
373
  ### `deny(operation, condition?, options?)`
395
374
 
396
- Block access when condition is true (overrides allow).
397
-
398
375
  ```typescript
399
376
  // Basic deny
400
377
  deny('delete', ctx => ctx.row.status === 'published')
@@ -402,10 +379,10 @@ deny('delete', ctx => ctx.row.status === 'published')
402
379
  // Deny all operations
403
380
  deny('all', ctx => ctx.auth.attributes?.banned === true)
404
381
 
405
- // Unconditional deny (no condition)
382
+ // Unconditional deny
406
383
  deny('all') // Always deny
407
384
 
408
- // With high priority
385
+ // With priority
409
386
  deny('all', ctx => ctx.auth.suspended, {
410
387
  name: 'block-suspended-users',
411
388
  priority: 200
@@ -414,8 +391,6 @@ deny('all', ctx => ctx.auth.suspended, {
414
391
 
415
392
  ### `filter(operation, condition, options?)`
416
393
 
417
- Add WHERE conditions to SELECT queries.
418
-
419
394
  ```typescript
420
395
  // Simple filter
421
396
  filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
@@ -435,84 +410,45 @@ filter('read', ctx => {
435
410
  return { status: 'published', public: true };
436
411
  })
437
412
 
438
- // With options
413
+ // With hints
439
414
  filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
440
415
  name: 'tenant-isolation',
441
- priority: 1000, // High priority for tenant isolation
416
+ priority: 1000,
442
417
  hints: { indexColumns: ['tenant_id'], selectivity: 'high' }
443
418
  })
444
419
  ```
445
420
 
446
- **Note:** Filter policies only apply to `'read'` operations. Using `'all'` is automatically converted to `'read'`.
447
-
448
421
  ### `validate(operation, condition, options?)`
449
422
 
450
- Validate mutation data during CREATE/UPDATE.
451
-
452
423
  ```typescript
453
424
  // Validate create
454
425
  validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
455
426
 
456
427
  // Validate update
457
428
  validate('update', ctx => {
458
- // Only allow changing specific fields
459
429
  const allowedFields = ['title', 'content', 'tags'];
460
430
  return Object.keys(ctx.data).every(key => allowedFields.includes(key));
461
431
  })
462
432
 
463
- // Validate both create and update
464
- validate('all', ctx => {
465
- // Price must be positive
466
- return !ctx.data.price || ctx.data.price >= 0;
467
- })
468
-
469
- // Complex validation
470
- validate('update', ctx => {
471
- // Validate status transitions
472
- const { status } = ctx.data;
473
- if (!status) return true; // Not changing status
474
-
475
- const validTransitions = {
476
- draft: ['published', 'archived'],
477
- published: ['archived'],
478
- archived: [],
479
- };
480
-
481
- return validTransitions[ctx.row.status]?.includes(status) ?? false;
482
- })
483
-
484
- // With options
485
- validate('create', ctx => validateEmail(ctx.data.email), {
486
- name: 'validate-email-format'
487
- })
433
+ // Both create and update
434
+ validate('all', ctx => !ctx.data.price || ctx.data.price >= 0)
488
435
  ```
489
436
 
490
- **Note:** Validate policies apply to `'create'` and `'update'` operations. Using `'all'` applies to both.
491
-
492
437
  ### Policy Options
493
438
 
494
- All policy builders accept an optional `options` parameter:
495
-
496
439
  ```typescript
497
440
  interface PolicyOptions {
498
- /** Policy name for debugging and identification */
441
+ /** Policy name for debugging */
499
442
  name?: string;
500
443
 
501
- /** Priority (higher runs first, deny defaults to 100) */
444
+ /** Priority (higher runs first) */
502
445
  priority?: number;
503
446
 
504
- /** Performance optimization hints */
447
+ /** Performance hints */
505
448
  hints?: {
506
- /** Columns that should be indexed */
507
449
  indexColumns?: string[];
508
-
509
- /** Expected selectivity (high = filters many rows) */
510
450
  selectivity?: 'high' | 'medium' | 'low';
511
-
512
- /** Whether policy is leakproof (safe to execute early) */
513
451
  leakproof?: boolean;
514
-
515
- /** Whether policy result is stable for same inputs */
516
452
  stable?: boolean;
517
453
  };
518
454
  }
@@ -520,42 +456,24 @@ interface PolicyOptions {
520
456
 
521
457
  ---
522
458
 
523
- ## Context Management
459
+ ## RLS Context
524
460
 
525
461
  ### RLSContext Interface
526
462
 
527
- The RLS context contains all information needed for policy evaluation.
463
+ The RLS context is stored and managed using AsyncLocalStorage, providing automatic context propagation across async boundaries:
528
464
 
529
465
  ```typescript
530
466
  interface RLSContext<TUser = unknown, TMeta = unknown> {
531
- /** Authentication context (required) */
532
467
  auth: {
533
- /** User identifier */
534
- userId: string | number;
535
-
536
- /** User roles for RBAC */
537
- roles: string[];
538
-
539
- /** Tenant ID for multi-tenancy (optional) */
540
- tenantId?: string | number;
541
-
542
- /** Organization IDs (optional) */
468
+ userId: string | number; // Required
469
+ roles: string[]; // Required
470
+ tenantId?: string | number; // Optional
543
471
  organizationIds?: (string | number)[];
544
-
545
- /** Granular permissions (optional) */
546
472
  permissions?: string[];
547
-
548
- /** Custom user attributes (optional) */
549
473
  attributes?: Record<string, unknown>;
550
-
551
- /** Full user object (optional) */
552
474
  user?: TUser;
553
-
554
- /** System/admin bypass flag (default: false) */
555
- isSystem?: boolean;
475
+ isSystem?: boolean; // Default: false
556
476
  };
557
-
558
- /** Request context (optional) */
559
477
  request?: {
560
478
  requestId?: string;
561
479
  ipAddress?: string;
@@ -563,22 +481,26 @@ interface RLSContext<TUser = unknown, TMeta = unknown> {
563
481
  timestamp: Date;
564
482
  headers?: Record<string, string>;
565
483
  };
566
-
567
- /** Custom metadata (optional) */
568
484
  meta?: TMeta;
569
-
570
- /** Context creation timestamp */
571
485
  timestamp: Date;
572
486
  }
573
487
  ```
574
488
 
575
- ### `rlsContext.runAsync(context, fn)`
489
+ **Context Storage:** The plugin uses `AsyncLocalStorage` internally to store the RLS context, which:
490
+ - Automatically propagates through async/await chains
491
+ - Is isolated per request (no cross-contamination)
492
+ - Requires no manual passing of context objects
493
+ - Works seamlessly with transactions
576
494
 
577
- Run a function within an RLS context (async).
495
+ ### Context Management
578
496
 
579
- ```typescript
580
- import { rlsContext } from '@kysera/rls';
497
+ The RLS plugin provides a singleton `rlsContext` manager that wraps AsyncLocalStorage for context management.
498
+
499
+ #### `rlsContext.runAsync(context, fn)`
581
500
 
501
+ Run async function within RLS context (most common usage):
502
+
503
+ ```typescript
582
504
  await rlsContext.runAsync(
583
505
  {
584
506
  auth: {
@@ -590,33 +512,29 @@ await rlsContext.runAsync(
590
512
  timestamp: new Date(),
591
513
  },
592
514
  async () => {
593
- // All queries in this scope use the RLS context
515
+ // All queries within this block use this context
594
516
  const posts = await orm.posts.findAll();
595
- await orm.posts.create({ title: 'New Post', /* ... */ });
517
+
518
+ // Context propagates through async operations
519
+ await orm.posts.create({ title: 'New Post' });
596
520
  }
597
521
  );
598
522
  ```
599
523
 
600
- ### `rlsContext.run(context, fn)`
524
+ #### `rlsContext.run(context, fn)`
601
525
 
602
- Run a function within an RLS context (sync).
526
+ Run synchronous function within RLS context:
603
527
 
604
528
  ```typescript
605
- rlsContext.run(
606
- {
607
- auth: { userId: 123, roles: ['user'], isSystem: false },
608
- timestamp: new Date(),
609
- },
610
- () => {
611
- // Synchronous code
612
- const currentUserId = rlsContext.getContext().auth.userId;
613
- }
614
- );
529
+ const result = rlsContext.run(context, () => {
530
+ // Synchronous operations
531
+ return someValue;
532
+ });
615
533
  ```
616
534
 
617
- ### `createRLSContext(options)`
535
+ #### `createRLSContext(options)`
618
536
 
619
- Create an RLS context object with validation.
537
+ Create and validate RLS context with proper defaults:
620
538
 
621
539
  ```typescript
622
540
  import { createRLSContext } from '@kysera/rls';
@@ -626,326 +544,301 @@ const ctx = createRLSContext({
626
544
  userId: 123,
627
545
  roles: ['user', 'editor'],
628
546
  tenantId: 'acme-corp',
629
- organizationIds: ['org-1'],
630
547
  permissions: ['posts:read', 'posts:write'],
631
- isSystem: false,
632
548
  },
633
- timestamp: new Date(),
549
+ // Optional request context
550
+ request: {
551
+ requestId: 'req-abc123',
552
+ ipAddress: '192.168.1.1',
553
+ timestamp: new Date(),
554
+ },
555
+ // Optional metadata
556
+ meta: {
557
+ featureFlags: ['beta_access'],
558
+ },
634
559
  });
635
560
 
636
- // Use with runAsync
637
561
  await rlsContext.runAsync(ctx, async () => {
638
562
  // ...
639
563
  });
640
564
  ```
641
565
 
642
- ### `withRLSContext(context, fn)`
643
-
644
- Helper for async context execution (alternative to `runAsync`).
645
-
646
- ```typescript
647
- import { withRLSContext } from '@kysera/rls';
648
-
649
- const result = await withRLSContext(
650
- {
651
- auth: { userId: 123, roles: ['user'], isSystem: false },
652
- timestamp: new Date(),
653
- },
654
- async () => {
655
- return await orm.posts.findAll();
656
- }
657
- );
658
- ```
566
+ #### Context Helper Methods
659
567
 
660
- ### Context Helpers
568
+ The `rlsContext` singleton provides helper methods for accessing context:
661
569
 
662
570
  ```typescript
663
- // Get current context (throws if not set)
571
+ // Get current context (throws RLSContextError if not set)
664
572
  const ctx = rlsContext.getContext();
665
573
 
666
- // Get current context or null (safe)
574
+ // Get context or null (safe, no throw)
667
575
  const ctx = rlsContext.getContextOrNull();
668
576
 
669
- // Check if context exists
577
+ // Check if running within context
670
578
  if (rlsContext.hasContext()) {
671
579
  // Context is available
672
580
  }
673
581
 
582
+ // Get auth context (throws if no context)
583
+ const auth = rlsContext.getAuth();
584
+
585
+ // Get user ID (throws if no context)
586
+ const userId = rlsContext.getUserId();
587
+
588
+ // Get tenant ID (throws if no context)
589
+ const tenantId = rlsContext.getTenantId();
590
+
591
+ // Check if user has role
592
+ if (rlsContext.hasRole('admin')) {
593
+ // User has admin role
594
+ }
595
+
596
+ // Check if user has permission
597
+ if (rlsContext.hasPermission('posts:delete')) {
598
+ // User can delete posts
599
+ }
600
+
601
+ // Check if running in system context
602
+ if (rlsContext.isSystem()) {
603
+ // Bypasses RLS policies
604
+ }
605
+
674
606
  // Run as system user (bypass RLS)
675
607
  await rlsContext.asSystemAsync(async () => {
676
- // All queries bypass RLS policies
608
+ // All operations bypass RLS policies
677
609
  const allPosts = await orm.posts.findAll();
678
610
  });
611
+
612
+ // Synchronous system context
613
+ const result = rlsContext.asSystem(() => {
614
+ return someOperation();
615
+ });
679
616
  ```
680
617
 
618
+ **Important:** The context helpers (`getContext`, `getAuth`, etc.) throw `RLSContextError` if called outside of a context. Always use `getContextOrNull()` or `hasContext()` if you need to check conditionally.
619
+
681
620
  ---
682
621
 
683
- ## Common Patterns
622
+ ## Repository Extensions
684
623
 
685
- ### Multi-Tenant Isolation
624
+ When the RLS plugin is enabled, repositories are automatically extended with utility methods via the `extendRepository` hook:
686
625
 
687
- ```typescript
688
- const schema = defineRLSSchema<Database>({
689
- // Apply tenant isolation to all tables
690
- posts: {
691
- policies: [
692
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
693
- validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
694
- validate('update', ctx => !ctx.data.tenant_id ||
695
- ctx.data.tenant_id === ctx.auth.tenantId),
696
- ],
697
- defaultDeny: true,
698
- },
626
+ ### `withoutRLS(fn)`
699
627
 
700
- comments: {
701
- policies: [
702
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
703
- validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
704
- ],
705
- defaultDeny: true,
706
- },
628
+ Bypass RLS policies for specific operations by running them in a system context:
629
+
630
+ ```typescript
631
+ // Fetch all posts including other tenants (bypasses RLS)
632
+ const allPosts = await repo.withoutRLS(async () => {
633
+ return repo.findAll();
707
634
  });
708
635
 
709
- // Usage
710
- app.use(async (req, res, next) => {
711
- const user = await authenticate(req);
636
+ // Compare filtered vs unfiltered results
637
+ await rlsContext.runAsync(userContext, async () => {
638
+ const userPosts = await repo.findAll(); // Filtered by RLS policies
712
639
 
713
- await rlsContext.runAsync(
714
- {
715
- auth: {
716
- userId: user.id,
717
- tenantId: user.tenant_id, // Tenant from JWT/session
718
- roles: user.roles,
719
- isSystem: false,
720
- },
721
- timestamp: new Date(),
722
- },
723
- async () => {
724
- // Automatically filtered by tenant_id
725
- const posts = await orm.posts.findAll();
726
- res.json(posts);
727
- }
728
- );
640
+ const allPosts = await repo.withoutRLS(async () => {
641
+ return repo.findAll(); // Bypasses RLS, returns all records
642
+ });
643
+
644
+ console.log(`User can see ${userPosts.length} of ${allPosts.length} total posts`);
729
645
  });
730
646
  ```
731
647
 
732
- ### Owner-Based Access
648
+ **Implementation:** `withoutRLS` internally calls `rlsContext.asSystemAsync(fn)`, which sets `auth.isSystem = true` for the duration of the callback.
649
+
650
+ ### `canAccess(operation, row)`
651
+
652
+ Check if the current user can perform an operation on a specific row:
733
653
 
734
654
  ```typescript
735
- const schema = defineRLSSchema<Database>({
736
- posts: {
737
- policies: [
738
- // Users can read all public posts
739
- filter('read', ctx => ({ public: true })),
655
+ const post = await repo.findById(postId);
740
656
 
741
- // Users can read their own posts (public or private)
742
- allow('read', ctx => ctx.auth.userId === ctx.row.author_id),
657
+ // Check read access
658
+ const canRead = await repo.canAccess('read', post);
743
659
 
744
- // Users can update/delete only their own posts
745
- allow(['update', 'delete'], ctx =>
746
- ctx.auth.userId === ctx.row.author_id
747
- ),
660
+ // Check update access before showing edit UI
661
+ const canUpdate = await repo.canAccess('update', post);
662
+ if (canUpdate) {
663
+ // Show edit button in UI
664
+ }
748
665
 
749
- // Users can create posts
750
- allow('create', ctx => true),
666
+ // Pre-flight check to avoid policy violations
667
+ if (await repo.canAccess('delete', post)) {
668
+ await repo.delete(post.id);
669
+ } else {
670
+ console.log('User cannot delete this post');
671
+ }
751
672
 
752
- // Set author_id automatically
753
- validate('create', ctx => ctx.data.author_id === ctx.auth.userId),
754
- ],
755
- defaultDeny: true,
756
- },
757
- });
673
+ // Check multiple operations
674
+ const operations = ['read', 'update', 'delete'] as const;
675
+ for (const op of operations) {
676
+ const allowed = await repo.canAccess(op, post);
677
+ console.log(`${op}: ${allowed}`);
678
+ }
758
679
  ```
759
680
 
760
- ### Role-Based Access Control (RBAC)
681
+ **Implementation:** `canAccess` evaluates the RLS policies against the provided row using the `MutationGuard`, returning `true` if allowed and `false` if denied or no context exists.
761
682
 
762
- ```typescript
763
- const schema = defineRLSSchema<Database>({
764
- posts: {
765
- policies: [
766
- // Admins can do everything
767
- allow('all', ctx => ctx.auth.roles.includes('admin')),
683
+ **Supported Operations:**
684
+ - `'read'` - Check if user can view the row
685
+ - `'create'` - Check if user can create with this data
686
+ - `'update'` - Check if user can update the row
687
+ - `'delete'` - Check if user can delete the row
768
688
 
769
- // Editors can read and update
770
- allow(['read', 'update'], ctx =>
771
- ctx.auth.roles.includes('editor')
772
- ),
689
+ ---
773
690
 
774
- // Authors can create and edit their own
775
- allow(['read', 'update', 'delete'], ctx =>
776
- ctx.auth.roles.includes('author') &&
777
- ctx.auth.userId === ctx.row.author_id
778
- ),
691
+ ## DAL Pattern Support
779
692
 
780
- // Regular users can only read published posts
781
- allow('read', ctx =>
782
- ctx.auth.roles.includes('user') &&
783
- ctx.row.status === 'published'
784
- ),
693
+ RLS works seamlessly with the DAL pattern:
694
+
695
+ ```typescript
696
+ import { createExecutor } from '@kysera/executor';
697
+ import { createContext, createQuery, withTransaction } from '@kysera/dal';
698
+ import { rlsPlugin, defineRLSSchema, filter, rlsContext } from '@kysera/rls';
699
+
700
+ // Define schema
701
+ const rlsSchema = defineRLSSchema<Database>({
702
+ posts: {
703
+ policies: [
704
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
785
705
  ],
786
- defaultDeny: true,
787
706
  },
788
707
  });
789
- ```
790
708
 
791
- ### Status-Based Restrictions
709
+ // Create executor with RLS
710
+ const executor = await createExecutor(db, [
711
+ rlsPlugin({ schema: rlsSchema }),
712
+ ]);
792
713
 
793
- ```typescript
794
- const schema = defineRLSSchema<Database>({
795
- posts: {
796
- policies: [
797
- // Can't delete published posts
798
- deny('delete', ctx => ctx.row.status === 'published'),
799
-
800
- // Can only update drafts and pending
801
- allow('update', ctx =>
802
- ['draft', 'pending'].includes(ctx.row.status)
803
- ),
804
-
805
- // Validate status transitions
806
- validate('update', ctx => {
807
- if (!ctx.data.status) return true;
714
+ // Create DAL context
715
+ const dalCtx = createContext(executor);
808
716
 
809
- const transitions = {
810
- draft: ['pending', 'published'],
811
- pending: ['published', 'draft'],
812
- published: ['archived'],
813
- archived: [],
814
- };
717
+ // Define queries - RLS applied automatically
718
+ const getPosts = createQuery((ctx) =>
719
+ ctx.db.selectFrom('posts').selectAll().execute()
720
+ );
815
721
 
816
- return transitions[ctx.row.status]?.includes(ctx.data.status) ?? false;
817
- }),
818
- ],
722
+ // Execute within RLS context
723
+ await rlsContext.runAsync(
724
+ {
725
+ auth: { userId: 1, tenantId: 'acme', roles: ['user'], isSystem: false },
726
+ timestamp: new Date(),
819
727
  },
820
- });
728
+ async () => {
729
+ // Automatically filtered by tenant
730
+ const posts = await getPosts(dalCtx);
731
+
732
+ // Transactions propagate RLS context
733
+ await withTransaction(dalCtx, async (txCtx) => {
734
+ const txPosts = await getPosts(txCtx);
735
+ });
736
+ }
737
+ );
821
738
  ```
822
739
 
823
740
  ---
824
741
 
825
- ## Native PostgreSQL RLS
826
-
827
- Generate native database-level RLS policies for defense-in-depth security.
828
-
829
- ### `PostgresRLSGenerator`
742
+ ## Plugin Configuration
830
743
 
831
- Generate PostgreSQL `CREATE POLICY` statements from your RLS schema.
744
+ ### `rlsPlugin(options)`
832
745
 
833
746
  ```typescript
834
- import { PostgresRLSGenerator } from '@kysera/rls/native';
835
-
836
- const generator = new PostgresRLSGenerator(rlsSchema, {
837
- force: true, // Force RLS on table owners
838
- schemaName: 'public', // Schema name (default: public)
839
- policyPrefix: 'rls_', // Prefix for generated policy names
840
- });
747
+ interface RLSPluginOptions<DB = unknown> {
748
+ /** RLS policy schema (required) */
749
+ schema: RLSSchema<DB>;
841
750
 
842
- // Generate policies for a table
843
- const sql = generator.generatePolicies('posts');
844
- console.log(sql);
845
-
846
- /*
847
- Output:
848
- ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
849
- ALTER TABLE posts FORCE ROW LEVEL SECURITY;
850
-
851
- CREATE POLICY rls_tenant_isolation ON posts
852
- FOR ALL
853
- USING (tenant_id = current_user_tenant_id());
854
-
855
- CREATE POLICY rls_author_access ON posts
856
- FOR UPDATE
857
- USING (author_id = current_user_id());
858
- */
859
- ```
751
+ /** Tables to skip RLS (always bypass) */
752
+ skipTables?: string[];
860
753
 
861
- ### `RLSMigrationGenerator`
754
+ /** Roles that bypass RLS entirely */
755
+ bypassRoles?: string[];
862
756
 
863
- Generate migration files for PostgreSQL RLS policies.
757
+ /** Logger for RLS operations */
758
+ logger?: KyseraLogger;
864
759
 
865
- ```typescript
866
- import { RLSMigrationGenerator } from '@kysera/rls/native';
867
-
868
- const migrationGen = new RLSMigrationGenerator(rlsSchema, {
869
- force: true, // Force RLS on table owners
870
- schemaName: 'public', // Schema name (default: public)
871
- policyPrefix: 'rls_', // Prefix for generated policy names
872
- migrationPath: './migrations',
873
- timestamp: true,
874
- });
760
+ /** Require RLS context (throws if missing) */
761
+ requireContext?: boolean;
875
762
 
876
- // Generate migration
877
- const { up, down } = migrationGen.generateMigration();
763
+ /** Enable audit logging of decisions */
764
+ auditDecisions?: boolean;
878
765
 
879
- console.log('--- UP ---');
880
- console.log(up);
881
- console.log('--- DOWN ---');
882
- console.log(down);
766
+ /** Custom violation handler */
767
+ onViolation?: (violation: RLSPolicyViolation) => void;
768
+ }
883
769
  ```
884
770
 
885
- ### `syncContextToPostgres`
886
-
887
- Sync RLS context to PostgreSQL session variables.
771
+ **Example:**
888
772
 
889
773
  ```typescript
890
- import { syncContextToPostgres } from '@kysera/rls/native';
891
- import { db } from './database';
892
-
893
- await rlsContext.runAsync(
894
- {
895
- auth: { userId: 123, tenantId: 'acme', roles: ['user'], isSystem: false },
896
- timestamp: new Date(),
774
+ import { rlsPlugin } from '@kysera/rls';
775
+ import { createLogger } from '@kysera/core';
776
+
777
+ const plugin = rlsPlugin({
778
+ schema: rlsSchema,
779
+ skipTables: ['audit_logs', 'migrations'],
780
+ bypassRoles: ['admin', 'system'],
781
+ logger: createLogger({ level: 'info' }),
782
+ requireContext: true,
783
+ auditDecisions: true,
784
+ onViolation: (violation) => {
785
+ auditLog.record({
786
+ type: 'rls_violation',
787
+ operation: violation.operation,
788
+ table: violation.table,
789
+ timestamp: new Date(),
790
+ });
897
791
  },
898
- async () => {
899
- // Sync context to PostgreSQL session
900
- await syncContextToPostgres(db);
901
-
902
- // Now PostgreSQL policies can access:
903
- // current_setting('app.user_id')::integer = 123
904
- // current_setting('app.tenant_id')::text = 'acme'
905
- // current_setting('app.roles')::text[] = '{user}'
792
+ });
906
793
 
907
- // Execute raw SQL with native RLS
908
- await db.executeQuery(sql`SELECT * FROM posts`);
909
- }
910
- );
794
+ const orm = await createORM(db, [plugin]);
911
795
  ```
912
796
 
913
797
  ---
914
798
 
915
799
  ## Error Handling
916
800
 
801
+ The RLS plugin provides specialized error classes for different failure scenarios:
802
+
917
803
  ### Error Types
918
804
 
919
805
  ```typescript
920
806
  import {
921
- RLSError,
922
- RLSContextError,
923
- RLSPolicyViolation,
924
- RLSSchemaError,
925
- RLSContextValidationError,
926
- RLSErrorCodes,
807
+ RLSError, // Base error class
808
+ RLSContextError, // Missing context
809
+ RLSPolicyViolation, // Access denied (expected)
810
+ RLSPolicyEvaluationError, // Bug in policy code (unexpected)
811
+ RLSSchemaError, // Invalid schema
812
+ RLSContextValidationError, // Invalid context
927
813
  } from '@kysera/rls';
928
814
  ```
929
815
 
816
+ ### Error Scenarios
817
+
930
818
  #### `RLSContextError`
931
819
 
932
- Thrown when RLS context is missing or not set.
820
+ Thrown when RLS context is missing but required:
933
821
 
934
822
  ```typescript
935
823
  try {
936
- // No RLS context set
824
+ // No context set, but requireContext: true
937
825
  await orm.posts.findAll();
938
826
  } catch (error) {
939
827
  if (error instanceof RLSContextError) {
940
- console.error('RLS context required:', error.message);
941
828
  // error.code === 'RLS_CONTEXT_MISSING'
829
+ console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()');
942
830
  }
943
831
  }
944
832
  ```
945
833
 
834
+ **When thrown:**
835
+ - Operations executed outside `rlsContext.runAsync()` when `requireContext: true`
836
+ - Calling `rlsContext.getContext()` without active context
837
+ - Attempting `asSystem()` without existing context
838
+
946
839
  #### `RLSPolicyViolation`
947
840
 
948
- Thrown when a database operation is denied by RLS policies.
841
+ Thrown when operation is denied by policies (this is expected, not a bug):
949
842
 
950
843
  ```typescript
951
844
  try {
@@ -953,389 +846,366 @@ try {
953
846
  await orm.posts.update(1, { title: 'New Title' });
954
847
  } catch (error) {
955
848
  if (error instanceof RLSPolicyViolation) {
956
- console.error('Policy violation:', {
957
- operation: error.operation, // 'update'
958
- table: error.table, // 'posts'
959
- reason: error.reason, // 'User does not own this post'
960
- policyName: error.policyName, // 'ownership_policy'
849
+ // error.code === 'RLS_POLICY_VIOLATION'
850
+ console.error({
851
+ operation: error.operation, // 'update'
852
+ table: error.table, // 'posts'
853
+ reason: error.reason, // 'User does not own this post'
854
+ policyName: error.policyName, // 'ownership_policy' (if named)
855
+ });
856
+
857
+ // Return 403 Forbidden to client
858
+ res.status(403).json({
859
+ error: 'Access denied',
860
+ message: error.reason,
961
861
  });
962
862
  }
963
863
  }
964
864
  ```
965
865
 
966
- ### Handling Violations
866
+ **When thrown:**
867
+ - `deny` policy condition evaluates to `true`
868
+ - No `allow` policy matches and `defaultDeny: true`
869
+ - `validate` policy fails during CREATE/UPDATE
967
870
 
968
- ```typescript
969
- import { rlsPlugin } from '@kysera/rls';
871
+ #### `RLSPolicyEvaluationError`
970
872
 
971
- const orm = await createORM(db, [
972
- rlsPlugin({
973
- schema: rlsSchema,
974
-
975
- // Custom violation handler
976
- onViolation: (violation) => {
977
- console.error('RLS Violation:', {
978
- operation: violation.operation,
979
- table: violation.table,
980
- reason: violation.reason,
981
- policyName: violation.policyName,
982
- });
983
-
984
- // Log to audit system
985
- auditLog.record({
986
- type: 'rls_violation',
987
- operation: violation.operation,
988
- table: violation.table,
989
- timestamp: new Date(),
990
- });
991
- },
873
+ Thrown when policy condition throws an error (this is a bug in your policy code):
992
874
 
993
- // Enable audit logging
994
- auditDecisions: true,
995
- }),
996
- ]);
875
+ ```typescript
876
+ try {
877
+ await orm.posts.findAll();
878
+ } catch (error) {
879
+ if (error instanceof RLSPolicyEvaluationError) {
880
+ // error.code === 'RLS_POLICY_EVALUATION_ERROR'
881
+ console.error({
882
+ operation: error.operation, // 'read'
883
+ table: error.table, // 'posts'
884
+ policyName: error.policyName, // 'tenant_filter'
885
+ originalError: error.originalError, // TypeError: Cannot read property 'tenantId' of undefined
886
+ });
887
+
888
+ // This is a bug - fix your policy code!
889
+ // Example: Policy tried to access ctx.auth.tenantId but it was undefined
890
+ }
891
+ }
997
892
  ```
998
893
 
999
- ---
894
+ **When thrown:**
895
+ - Policy condition function throws an error
896
+ - Policy tries to access undefined properties
897
+ - Async policy rejects with an error
1000
898
 
1001
- ## TypeScript Support
899
+ **Debugging:** The `originalError` property and stack trace are preserved to help identify the issue in your policy code.
900
+
901
+ #### `RLSContextValidationError`
1002
902
 
1003
- ### Full Type Inference
903
+ Thrown when RLS context is malformed:
1004
904
 
1005
905
  ```typescript
1006
- // Database schema
1007
- interface Database {
1008
- posts: {
1009
- id: number;
1010
- title: string;
1011
- author_id: number;
1012
- tenant_id: string;
1013
- };
906
+ try {
907
+ const ctx = createRLSContext({
908
+ auth: {
909
+ // Missing userId!
910
+ roles: ['user'],
911
+ },
912
+ });
913
+ } catch (error) {
914
+ if (error instanceof RLSContextValidationError) {
915
+ // error.code === 'RLS_CONTEXT_INVALID'
916
+ console.error({
917
+ message: error.message, // 'userId is required in auth context'
918
+ field: error.field, // 'userId'
919
+ });
920
+ }
1014
921
  }
922
+ ```
1015
923
 
1016
- // Type-safe policy definition
1017
- const schema = defineRLSSchema<Database>({
1018
- posts: {
1019
- policies: [
1020
- // ctx.row is typed as Database['posts']
1021
- allow('read', ctx => {
1022
- const post = ctx.row; // Type: Database['posts']
1023
- const userId = ctx.auth.userId; // Type: string | number
1024
-
1025
- return post.author_id === userId;
1026
- }),
924
+ #### `RLSSchemaError`
1027
925
 
1028
- // ctx.data is typed as Partial<Database['posts']>
1029
- validate('update', ctx => {
1030
- const data = ctx.data; // Type: Partial<Database['posts']>
1031
- const title = data.title; // Type: string | undefined
926
+ Thrown when RLS schema is invalid:
1032
927
 
1033
- return !title || title.length > 0;
1034
- }),
1035
- ],
1036
- },
1037
- });
928
+ ```typescript
929
+ try {
930
+ const schema = defineRLSSchema({
931
+ posts: {
932
+ policies: [
933
+ // Invalid policy!
934
+ { type: 'invalid-type', operation: 'read', condition: () => true }
935
+ ]
936
+ }
937
+ });
938
+ } catch (error) {
939
+ if (error instanceof RLSSchemaError) {
940
+ // error.code === 'RLS_SCHEMA_INVALID'
941
+ console.error(error.details);
942
+ }
943
+ }
1038
944
  ```
1039
945
 
1040
- ---
946
+ ### Error Comparison Table
947
+
948
+ | Error | Meaning | Severity | Action |
949
+ |-------|---------|----------|--------|
950
+ | `RLSContextError` | Missing context | Error | Ensure code runs in `rlsContext.runAsync()` |
951
+ | `RLSPolicyViolation` | Access denied | Expected | Return 403 to client, normal behavior |
952
+ | `RLSPolicyEvaluationError` | Policy bug | Critical | Fix the policy code immediately |
953
+ | `RLSContextValidationError` | Invalid context | Error | Fix context creation |
954
+ | `RLSSchemaError` | Invalid schema | Error | Fix schema definition |
1041
955
 
1042
- ## Testing
956
+ ### Error Codes
1043
957
 
1044
- ### Unit Testing Policies
958
+ All RLS errors include a `code` property for programmatic handling:
1045
959
 
1046
960
  ```typescript
1047
- import { describe, it, expect } from 'vitest';
1048
- import { allow, filter, validate } from '@kysera/rls';
1049
-
1050
- describe('Post Policies', () => {
1051
- it('should allow owner to update post', () => {
1052
- const policy = allow('update', ctx =>
1053
- ctx.auth.userId === ctx.row.author_id
1054
- );
1055
-
1056
- const context = {
1057
- auth: { userId: 123, roles: [], isSystem: false },
1058
- row: { author_id: 123 },
1059
- };
1060
-
1061
- const result = policy.condition(context as any);
1062
- expect(result).toBe(true);
1063
- });
961
+ import { RLSErrorCodes } from '@kysera/rls';
962
+
963
+ // RLSErrorCodes.RLS_CONTEXT_MISSING
964
+ // RLSErrorCodes.RLS_POLICY_VIOLATION
965
+ // RLSErrorCodes.RLS_POLICY_EVALUATION_ERROR
966
+ // RLSErrorCodes.RLS_CONTEXT_INVALID
967
+ // RLSErrorCodes.RLS_SCHEMA_INVALID
968
+ // RLSErrorCodes.RLS_POLICY_INVALID
969
+ ```
1064
970
 
1065
- it('should filter posts by tenant', () => {
1066
- const policy = filter('read', ctx => ({
1067
- tenant_id: ctx.auth.tenantId
1068
- }));
971
+ ---
1069
972
 
1070
- const context = {
1071
- auth: { userId: 123, tenantId: 'acme', roles: [], isSystem: false },
1072
- };
973
+ ## Architecture & Implementation
1073
974
 
1074
- const result = policy.condition(context as any);
1075
- expect(result).toEqual({ tenant_id: 'acme' });
1076
- });
1077
- });
1078
- ```
975
+ ### Plugin Lifecycle
1079
976
 
1080
- ### Integration Testing
977
+ The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:
1081
978
 
1082
- ```typescript
1083
- import { describe, it, beforeEach, expect } from 'vitest';
1084
- import { createORM } from '@kysera/repository';
1085
- import { rlsPlugin, rlsContext, defineRLSSchema } from '@kysera/rls';
979
+ 1. **Initialization (`onInit`):**
980
+ - Creates `PolicyRegistry` from schema
981
+ - Validates all policies
982
+ - Compiles policies for runtime
983
+ - Creates `SelectTransformer` and `MutationGuard` instances
1086
984
 
1087
- describe('RLS Integration', () => {
1088
- let orm: ReturnType<typeof createORM>;
985
+ 2. **Query Interception (`interceptQuery`):**
986
+ - Called for every query builder operation
987
+ - Checks skip conditions (skipTables, metadata, system user, bypass roles)
988
+ - For SELECT: Applies filter policies via `SelectTransformer`
989
+ - For mutations: Marks `metadata['__rlsRequired'] = true`
1089
990
 
1090
- beforeEach(async () => {
1091
- const schema = defineRLSSchema<Database>({
1092
- posts: {
1093
- policies: [
1094
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
1095
- ],
1096
- },
1097
- });
991
+ 3. **Repository Extension (`extendRepository`):**
992
+ - Wraps `create`, `update`, `delete` methods
993
+ - Evaluates policies via `MutationGuard`
994
+ - Uses `getRawDb()` to fetch existing rows (bypasses RLS)
995
+ - Adds `withoutRLS()` and `canAccess()` utility methods
1098
996
 
1099
- orm = await createORM(db, [rlsPlugin({ schema })]);
1100
- });
997
+ ### Key Components
1101
998
 
1102
- it('should filter posts by tenant', async () => {
1103
- await rlsContext.runAsync(
1104
- {
1105
- auth: {
1106
- userId: 1,
1107
- tenantId: 'tenant-1',
1108
- roles: ['user'],
1109
- isSystem: false,
1110
- },
1111
- timestamp: new Date(),
1112
- },
1113
- async () => {
1114
- const posts = await orm.posts.findAll();
999
+ **PolicyRegistry:**
1000
+ - Stores and indexes compiled policies by table and operation
1001
+ - Validates schema structure
1002
+ - Provides fast policy lookup
1115
1003
 
1116
- // All posts should belong to tenant-1
1117
- expect(posts.every(p => p.tenant_id === 'tenant-1')).toBe(true);
1118
- }
1119
- );
1120
- });
1121
- });
1122
- ```
1004
+ **SelectTransformer:**
1005
+ - Transforms SELECT queries by adding WHERE conditions
1006
+ - Combines multiple filter policies with AND logic
1007
+ - Evaluates filter conditions in context
1123
1008
 
1124
- ---
1009
+ **MutationGuard:**
1010
+ - Evaluates allow/deny policies for mutations
1011
+ - Enforces policy evaluation order (deny → allow → validate)
1012
+ - Throws `RLSPolicyViolation` or `RLSPolicyEvaluationError`
1125
1013
 
1126
- ## API Reference
1014
+ **AsyncLocalStorage:**
1015
+ - Provides context isolation per request
1016
+ - Automatic propagation through async/await chains
1017
+ - No manual context passing required
1127
1018
 
1128
- ### Core Exports
1019
+ ### Performance Considerations
1129
1020
 
1130
- ```typescript
1131
- // Schema definition
1132
- export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls';
1021
+ **Compiled Policies:**
1022
+ - Policies are compiled once at initialization
1023
+ - No runtime parsing or compilation overhead
1133
1024
 
1134
- // Policy builders
1135
- export { allow, deny, filter, validate } from '@kysera/rls';
1025
+ **Filter Application:**
1026
+ - Filters applied as SQL WHERE clauses
1027
+ - Database handles filtering efficiently
1028
+ - Index hints available via `PolicyOptions.hints`
1136
1029
 
1137
- // Plugin
1138
- export { rlsPlugin } from '@kysera/rls';
1139
- export type { RLSPluginOptions } from '@kysera/rls';
1030
+ **Context Access:**
1031
+ - AsyncLocalStorage is very fast (V8-optimized)
1032
+ - Context lookup has negligible overhead
1140
1033
 
1141
- // Context management
1142
- export {
1143
- rlsContext,
1144
- createRLSContext,
1145
- withRLSContext,
1146
- withRLSContextAsync,
1147
- } from '@kysera/rls';
1148
- export type { RLSContext } from '@kysera/rls';
1034
+ **Bypass Mechanisms:**
1035
+ - System context bypass is immediate (no policy evaluation)
1036
+ - `skipTables` bypass is immediate (no policy evaluation)
1037
+ - Bypass roles checked before policy evaluation
1149
1038
 
1150
- // Errors
1151
- export {
1152
- RLSError,
1153
- RLSContextError,
1154
- RLSPolicyViolation,
1155
- RLSSchemaError,
1156
- RLSContextValidationError,
1157
- RLSErrorCodes,
1158
- } from '@kysera/rls';
1159
- ```
1039
+ ### Transaction Support
1160
1040
 
1161
- ### Native PostgreSQL Exports
1041
+ RLS context automatically propagates through transactions:
1162
1042
 
1163
1043
  ```typescript
1164
- // Import from @kysera/rls/native
1165
- export {
1166
- PostgresRLSGenerator,
1167
- syncContextToPostgres,
1168
- clearPostgresContext,
1169
- RLSMigrationGenerator,
1170
- } from '@kysera/rls/native';
1044
+ await rlsContext.runAsync(userContext, async () => {
1045
+ // Context available in transaction
1046
+ await orm.transaction(async (trx) => {
1047
+ // All queries use the same RLS context
1048
+ const user = await trx.users.findById(userId);
1049
+ await trx.posts.create({ title: 'Post', authorId: userId });
1050
+ });
1051
+ });
1171
1052
  ```
1172
1053
 
1173
- ### Repository Extensions
1174
-
1175
- When using the RLS plugin, repositories are extended with:
1054
+ **Note:** DAL transactions with executor preserve RLS context:
1176
1055
 
1177
1056
  ```typescript
1178
- interface RLSRepositoryExtensions {
1179
- /**
1180
- * Bypass RLS for specific operation
1181
- * Requires existing context
1182
- */
1183
- withoutRLS<R>(fn: () => Promise<R>): Promise<R>;
1184
-
1185
- /**
1186
- * Check if current user can perform operation on a row
1187
- */
1188
- canAccess(operation: Operation, row: Record<string, unknown>): Promise<boolean>;
1189
- }
1190
-
1191
- // Usage
1192
- const canEdit = await repo.canAccess('update', post);
1193
- if (canEdit) {
1194
- await repo.update(post.id, { title: 'New Title' });
1195
- }
1196
-
1197
- // Bypass RLS (requires system context or bypass role)
1198
- const allPosts = await repo.withoutRLS(async () => {
1199
- return repo.findAll(); // No RLS filtering
1057
+ await withTransaction(executor, async (txCtx) => {
1058
+ // RLS context preserved in transaction
1059
+ const posts = await getPosts(txCtx);
1200
1060
  });
1201
1061
  ```
1202
1062
 
1203
1063
  ---
1204
1064
 
1205
- ## Security Considerations
1206
-
1207
- ### Context Validation
1065
+ ## Common Patterns
1208
1066
 
1209
- Always validate RLS context before use:
1067
+ ### Multi-Tenant Isolation
1210
1068
 
1211
1069
  ```typescript
1212
- import { createRLSContext, RLSContextValidationError } from '@kysera/rls';
1213
-
1214
- try {
1215
- const ctx = createRLSContext({
1216
- auth: {
1217
- userId: user.id, // Required
1218
- roles: user.roles, // Required (array)
1219
- tenantId: user.tenant, // Optional
1220
- },
1221
- });
1222
- } catch (error) {
1223
- if (error instanceof RLSContextValidationError) {
1224
- // Handle invalid context
1225
- }
1226
- }
1227
- ```
1228
-
1229
- ### SQL Injection Prevention
1230
-
1231
- All filter conditions are parameterized - never construct SQL from user input:
1070
+ const schema = defineRLSSchema<Database>({
1071
+ posts: {
1072
+ policies: [
1073
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
1074
+ validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
1075
+ ],
1076
+ defaultDeny: true,
1077
+ },
1078
+ });
1232
1079
 
1233
- ```typescript
1234
- // Safe - values are parameterized
1235
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
1080
+ app.use(async (req, res, next) => {
1081
+ const user = await authenticate(req);
1236
1082
 
1237
- // ❌ Never do this - raw SQL from user input
1238
- filter('read', ctx => sql.raw(`tenant_id = '${userInput}'`))
1083
+ await rlsContext.runAsync(
1084
+ { auth: { userId: user.id, tenantId: user.tenant_id, roles: user.roles } },
1085
+ async () => {
1086
+ const posts = await orm.posts.findAll();
1087
+ res.json(posts);
1088
+ }
1089
+ );
1090
+ });
1239
1091
  ```
1240
1092
 
1241
- ### Defense in Depth
1242
-
1243
- For maximum security, combine ORM-level RLS with native PostgreSQL RLS:
1093
+ ### Owner-Based Access
1244
1094
 
1245
1095
  ```typescript
1246
- const orm = await createORM(db, [
1247
- rlsPlugin({
1248
- schema: rlsSchema,
1249
- nativeSync: true, // Generate PostgreSQL RLS policies
1250
- }),
1251
- ]);
1252
- ```
1253
-
1254
- ### System User Access
1096
+ const schema = defineRLSSchema<Database>({
1097
+ posts: {
1098
+ policies: [
1099
+ // Public posts visible to all
1100
+ filter('read', ctx => ({ public: true })),
1255
1101
 
1256
- The `isSystem: true` flag bypasses all RLS checks. Use sparingly:
1102
+ // Or own posts
1103
+ allow('read', ctx => ctx.auth.userId === ctx.row.author_id),
1257
1104
 
1258
- ```typescript
1259
- // Only for trusted system operations
1260
- await rlsContext.asSystemAsync(async () => {
1261
- await db.selectFrom('audit_logs').selectAll().execute();
1105
+ // Only owner can update/delete
1106
+ allow(['update', 'delete'], ctx =>
1107
+ ctx.auth.userId === ctx.row.author_id
1108
+ ),
1109
+ ],
1110
+ },
1262
1111
  });
1263
1112
  ```
1264
1113
 
1265
- ### Audit Logging
1266
-
1267
- Enable audit logging in production:
1114
+ ### Role-Based Access Control
1268
1115
 
1269
1116
  ```typescript
1270
- const orm = await createORM(db, [
1271
- rlsPlugin({
1272
- schema: rlsSchema,
1273
- auditDecisions: true, // Log all policy decisions
1274
- onViolation: (violation) => {
1275
- logger.warn('RLS violation', {
1276
- operation: violation.operation,
1277
- table: violation.table,
1278
- userId: violation.userId,
1279
- });
1280
- },
1281
- }),
1282
- ]);
1283
- ```
1117
+ const schema = defineRLSSchema<Database>({
1118
+ posts: {
1119
+ policies: [
1120
+ // Admins can do everything
1121
+ allow('all', ctx => ctx.auth.roles.includes('admin')),
1284
1122
 
1285
- ---
1123
+ // Editors can read and update
1124
+ allow(['read', 'update'], ctx =>
1125
+ ctx.auth.roles.includes('editor')
1126
+ ),
1286
1127
 
1287
- ## Performance Tips
1128
+ // Regular users read only
1129
+ allow('read', ctx => ctx.auth.roles.includes('user')),
1130
+ ],
1131
+ },
1132
+ });
1133
+ ```
1288
1134
 
1289
- ### Index Filter Columns
1135
+ ---
1290
1136
 
1291
- Ensure columns used in filter policies are indexed:
1137
+ ## TypeScript Support
1292
1138
 
1293
- ```sql
1294
- -- tenant_id is commonly used in RLS filters
1295
- CREATE INDEX idx_posts_tenant ON posts (tenant_id);
1296
- CREATE INDEX idx_resources_tenant ON resources (tenant_id);
1297
- ```
1139
+ Full type inference for policies:
1298
1140
 
1299
- ### Use Hints for Native RLS
1141
+ ```typescript
1142
+ interface Database {
1143
+ posts: {
1144
+ id: number;
1145
+ title: string;
1146
+ author_id: number;
1147
+ tenant_id: string;
1148
+ };
1149
+ }
1300
1150
 
1301
- When generating native PostgreSQL policies, use hints:
1151
+ const schema = defineRLSSchema<Database>({
1152
+ posts: {
1153
+ policies: [
1154
+ allow('read', ctx => {
1155
+ const post = ctx.row; // Type: Database['posts']
1156
+ const userId = ctx.auth.userId; // Type: string | number
1157
+ return post.author_id === userId;
1158
+ }),
1302
1159
 
1303
- ```typescript
1304
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
1305
- hints: {
1306
- indexColumns: ['tenant_id'],
1307
- selectivity: 'high', // Many rows per tenant
1160
+ validate('update', ctx => {
1161
+ const data = ctx.data; // Type: Partial<Database['posts']>
1162
+ const title = data.title; // Type: string | undefined
1163
+ return !title || title.length > 0;
1164
+ }),
1165
+ ],
1308
1166
  },
1309
- })
1167
+ });
1310
1168
  ```
1311
1169
 
1312
- ### Avoid Async Policies for Hot Paths
1170
+ ---
1171
+
1172
+ ## API Reference
1313
1173
 
1314
- Sync policies are faster than async:
1174
+ ### Core Exports
1315
1175
 
1316
1176
  ```typescript
1317
- // Fast - synchronous evaluation
1318
- allow('read', ctx => ctx.auth.userId === ctx.row.owner_id)
1177
+ // Schema definition
1178
+ export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls';
1319
1179
 
1320
- // ⚠️ Slower - async evaluation (use when necessary)
1321
- allow('read', async ctx => {
1322
- const membership = await db.selectFrom('memberships')...
1323
- return membership !== undefined;
1324
- })
1325
- ```
1180
+ // Policy builders
1181
+ export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls';
1326
1182
 
1327
- ---
1183
+ // Plugin
1184
+ export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls';
1328
1185
 
1329
- ## Documentation
1186
+ // Context management
1187
+ export {
1188
+ rlsContext,
1189
+ createRLSContext,
1190
+ withRLSContext,
1191
+ withRLSContextAsync,
1192
+ type RLSContext,
1193
+ } from '@kysera/rls';
1330
1194
 
1331
- See [kysera-rls-spec.md](kysera-rls-spec.md) for detailed specification and architecture.
1195
+ // Errors
1196
+ export {
1197
+ RLSError,
1198
+ RLSContextError,
1199
+ RLSPolicyViolation,
1200
+ RLSPolicyEvaluationError,
1201
+ RLSSchemaError,
1202
+ RLSContextValidationError,
1203
+ RLSErrorCodes,
1204
+ } from '@kysera/rls';
1205
+ ```
1332
1206
 
1333
1207
  ---
1334
1208
 
1335
1209
  ## License
1336
1210
 
1337
1211
  MIT
1338
-
1339
- ---
1340
-
1341
- **Built with ❤️ by the Kysera Team**