@kysera/rls 0.6.1 → 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,327 +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
- RLSPolicyEvaluationError,
925
- RLSSchemaError,
926
- RLSContextValidationError,
927
- 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
928
813
  } from '@kysera/rls';
929
814
  ```
930
815
 
816
+ ### Error Scenarios
817
+
931
818
  #### `RLSContextError`
932
819
 
933
- Thrown when RLS context is missing or not set.
820
+ Thrown when RLS context is missing but required:
934
821
 
935
822
  ```typescript
936
823
  try {
937
- // No RLS context set
824
+ // No context set, but requireContext: true
938
825
  await orm.posts.findAll();
939
826
  } catch (error) {
940
827
  if (error instanceof RLSContextError) {
941
- console.error('RLS context required:', error.message);
942
828
  // error.code === 'RLS_CONTEXT_MISSING'
829
+ console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()');
943
830
  }
944
831
  }
945
832
  ```
946
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
+
947
839
  #### `RLSPolicyViolation`
948
840
 
949
- Thrown when a database operation is denied by RLS policies (legitimate access denial).
841
+ Thrown when operation is denied by policies (this is expected, not a bug):
950
842
 
951
843
  ```typescript
952
844
  try {
@@ -954,414 +846,366 @@ try {
954
846
  await orm.posts.update(1, { title: 'New Title' });
955
847
  } catch (error) {
956
848
  if (error instanceof RLSPolicyViolation) {
957
- console.error('Policy violation:', {
958
- operation: error.operation, // 'update'
959
- table: error.table, // 'posts'
960
- reason: error.reason, // 'User does not own this post'
961
- 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,
962
861
  });
963
862
  }
964
863
  }
965
864
  ```
966
865
 
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
870
+
967
871
  #### `RLSPolicyEvaluationError`
968
872
 
969
- Thrown when a policy fails to evaluate due to an error in the policy code itself (bug in policy implementation). This is distinct from `RLSPolicyViolation`, which represents legitimate access denial.
873
+ Thrown when policy condition throws an error (this is a bug in your policy code):
970
874
 
971
875
  ```typescript
972
876
  try {
973
- // Policy throws an error during evaluation
974
877
  await orm.posts.findAll();
975
878
  } catch (error) {
976
879
  if (error instanceof RLSPolicyEvaluationError) {
977
- console.error('Policy evaluation failed:', {
978
- operation: error.operation, // 'read'
979
- table: error.table, // 'posts'
980
- policyName: error.policyName, // 'tenant_filter'
981
- originalError: error.originalError, // The underlying error
982
- message: error.message, // Error message with context
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
983
886
  });
984
887
 
985
- // Original stack trace is preserved for debugging
986
- console.error('Stack trace:', error.stack);
888
+ // This is a bug - fix your policy code!
889
+ // Example: Policy tried to access ctx.auth.tenantId but it was undefined
987
890
  }
988
891
  }
989
892
  ```
990
893
 
991
- ### Handling Violations
894
+ **When thrown:**
895
+ - Policy condition function throws an error
896
+ - Policy tries to access undefined properties
897
+ - Async policy rejects with an error
992
898
 
993
- ```typescript
994
- import { rlsPlugin } from '@kysera/rls';
899
+ **Debugging:** The `originalError` property and stack trace are preserved to help identify the issue in your policy code.
995
900
 
996
- const orm = await createORM(db, [
997
- rlsPlugin({
998
- schema: rlsSchema,
999
-
1000
- // Custom violation handler
1001
- onViolation: (violation) => {
1002
- console.error('RLS Violation:', {
1003
- operation: violation.operation,
1004
- table: violation.table,
1005
- reason: violation.reason,
1006
- policyName: violation.policyName,
1007
- });
1008
-
1009
- // Log to audit system
1010
- auditLog.record({
1011
- type: 'rls_violation',
1012
- operation: violation.operation,
1013
- table: violation.table,
1014
- timestamp: new Date(),
1015
- });
1016
- },
901
+ #### `RLSContextValidationError`
1017
902
 
1018
- // Enable audit logging
1019
- auditDecisions: true,
1020
- }),
1021
- ]);
1022
- ```
903
+ Thrown when RLS context is malformed:
1023
904
 
1024
- ---
905
+ ```typescript
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
+ }
921
+ }
922
+ ```
1025
923
 
1026
- ## TypeScript Support
924
+ #### `RLSSchemaError`
1027
925
 
1028
- ### Full Type Inference
926
+ Thrown when RLS schema is invalid:
1029
927
 
1030
928
  ```typescript
1031
- // Database schema
1032
- interface Database {
1033
- posts: {
1034
- id: number;
1035
- title: string;
1036
- author_id: number;
1037
- tenant_id: string;
1038
- };
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
+ }
1039
943
  }
944
+ ```
1040
945
 
1041
- // Type-safe policy definition
1042
- const schema = defineRLSSchema<Database>({
1043
- posts: {
1044
- policies: [
1045
- // ctx.row is typed as Database['posts']
1046
- allow('read', ctx => {
1047
- const post = ctx.row; // Type: Database['posts']
1048
- const userId = ctx.auth.userId; // Type: string | number
946
+ ### Error Comparison Table
1049
947
 
1050
- return post.author_id === userId;
1051
- }),
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 |
1052
955
 
1053
- // ctx.data is typed as Partial<Database['posts']>
1054
- validate('update', ctx => {
1055
- const data = ctx.data; // Type: Partial<Database['posts']>
1056
- const title = data.title; // Type: string | undefined
956
+ ### Error Codes
1057
957
 
1058
- return !title || title.length > 0;
1059
- }),
1060
- ],
1061
- },
1062
- });
958
+ All RLS errors include a `code` property for programmatic handling:
959
+
960
+ ```typescript
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
1063
969
  ```
1064
970
 
1065
971
  ---
1066
972
 
1067
- ## Testing
1068
-
1069
- ### Unit Testing Policies
1070
-
1071
- ```typescript
1072
- import { describe, it, expect } from 'vitest';
1073
- import { allow, filter, validate } from '@kysera/rls';
1074
-
1075
- describe('Post Policies', () => {
1076
- it('should allow owner to update post', () => {
1077
- const policy = allow('update', ctx =>
1078
- ctx.auth.userId === ctx.row.author_id
1079
- );
1080
-
1081
- const context = {
1082
- auth: { userId: 123, roles: [], isSystem: false },
1083
- row: { author_id: 123 },
1084
- };
1085
-
1086
- const result = policy.condition(context as any);
1087
- expect(result).toBe(true);
1088
- });
973
+ ## Architecture & Implementation
1089
974
 
1090
- it('should filter posts by tenant', () => {
1091
- const policy = filter('read', ctx => ({
1092
- tenant_id: ctx.auth.tenantId
1093
- }));
975
+ ### Plugin Lifecycle
1094
976
 
1095
- const context = {
1096
- auth: { userId: 123, tenantId: 'acme', roles: [], isSystem: false },
1097
- };
977
+ The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:
1098
978
 
1099
- const result = policy.condition(context as any);
1100
- expect(result).toEqual({ tenant_id: 'acme' });
1101
- });
1102
- });
1103
- ```
979
+ 1. **Initialization (`onInit`):**
980
+ - Creates `PolicyRegistry` from schema
981
+ - Validates all policies
982
+ - Compiles policies for runtime
983
+ - Creates `SelectTransformer` and `MutationGuard` instances
1104
984
 
1105
- ### Integration Testing
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`
1106
990
 
1107
- ```typescript
1108
- import { describe, it, beforeEach, expect } from 'vitest';
1109
- import { createORM } from '@kysera/repository';
1110
- import { rlsPlugin, rlsContext, defineRLSSchema } from '@kysera/rls';
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
1111
996
 
1112
- describe('RLS Integration', () => {
1113
- let orm: ReturnType<typeof createORM>;
997
+ ### Key Components
1114
998
 
1115
- beforeEach(async () => {
1116
- const schema = defineRLSSchema<Database>({
1117
- posts: {
1118
- policies: [
1119
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
1120
- ],
1121
- },
1122
- });
999
+ **PolicyRegistry:**
1000
+ - Stores and indexes compiled policies by table and operation
1001
+ - Validates schema structure
1002
+ - Provides fast policy lookup
1123
1003
 
1124
- orm = await createORM(db, [rlsPlugin({ schema })]);
1125
- });
1004
+ **SelectTransformer:**
1005
+ - Transforms SELECT queries by adding WHERE conditions
1006
+ - Combines multiple filter policies with AND logic
1007
+ - Evaluates filter conditions in context
1126
1008
 
1127
- it('should filter posts by tenant', async () => {
1128
- await rlsContext.runAsync(
1129
- {
1130
- auth: {
1131
- userId: 1,
1132
- tenantId: 'tenant-1',
1133
- roles: ['user'],
1134
- isSystem: false,
1135
- },
1136
- timestamp: new Date(),
1137
- },
1138
- async () => {
1139
- const posts = await orm.posts.findAll();
1009
+ **MutationGuard:**
1010
+ - Evaluates allow/deny policies for mutations
1011
+ - Enforces policy evaluation order (deny → allow → validate)
1012
+ - Throws `RLSPolicyViolation` or `RLSPolicyEvaluationError`
1140
1013
 
1141
- // All posts should belong to tenant-1
1142
- expect(posts.every(p => p.tenant_id === 'tenant-1')).toBe(true);
1143
- }
1144
- );
1145
- });
1146
- });
1147
- ```
1014
+ **AsyncLocalStorage:**
1015
+ - Provides context isolation per request
1016
+ - Automatic propagation through async/await chains
1017
+ - No manual context passing required
1148
1018
 
1149
- ---
1019
+ ### Performance Considerations
1150
1020
 
1151
- ## API Reference
1021
+ **Compiled Policies:**
1022
+ - Policies are compiled once at initialization
1023
+ - No runtime parsing or compilation overhead
1152
1024
 
1153
- ### Core Exports
1025
+ **Filter Application:**
1026
+ - Filters applied as SQL WHERE clauses
1027
+ - Database handles filtering efficiently
1028
+ - Index hints available via `PolicyOptions.hints`
1154
1029
 
1155
- ```typescript
1156
- // Schema definition
1157
- export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls';
1030
+ **Context Access:**
1031
+ - AsyncLocalStorage is very fast (V8-optimized)
1032
+ - Context lookup has negligible overhead
1158
1033
 
1159
- // Policy builders
1160
- export { allow, deny, filter, validate } 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
1161
1038
 
1162
- // Plugin
1163
- export { rlsPlugin } from '@kysera/rls';
1164
- export type { RLSPluginOptions } from '@kysera/rls';
1039
+ ### Transaction Support
1165
1040
 
1166
- // Context management
1167
- export {
1168
- rlsContext,
1169
- createRLSContext,
1170
- withRLSContext,
1171
- withRLSContextAsync,
1172
- } from '@kysera/rls';
1173
- export type { RLSContext } from '@kysera/rls';
1174
-
1175
- // Errors
1176
- export {
1177
- RLSError,
1178
- RLSContextError,
1179
- RLSPolicyViolation,
1180
- RLSPolicyEvaluationError,
1181
- RLSSchemaError,
1182
- RLSContextValidationError,
1183
- RLSErrorCodes,
1184
- } from '@kysera/rls';
1185
- ```
1186
-
1187
- ### Native PostgreSQL Exports
1041
+ RLS context automatically propagates through transactions:
1188
1042
 
1189
1043
  ```typescript
1190
- // Import from @kysera/rls/native
1191
- export {
1192
- PostgresRLSGenerator,
1193
- syncContextToPostgres,
1194
- clearPostgresContext,
1195
- RLSMigrationGenerator,
1196
- } 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
+ });
1197
1052
  ```
1198
1053
 
1199
- ### Repository Extensions
1200
-
1201
- When using the RLS plugin, repositories are extended with:
1054
+ **Note:** DAL transactions with executor preserve RLS context:
1202
1055
 
1203
1056
  ```typescript
1204
- interface RLSRepositoryExtensions {
1205
- /**
1206
- * Bypass RLS for specific operation
1207
- * Requires existing context
1208
- */
1209
- withoutRLS<R>(fn: () => Promise<R>): Promise<R>;
1210
-
1211
- /**
1212
- * Check if current user can perform operation on a row
1213
- */
1214
- canAccess(operation: Operation, row: Record<string, unknown>): Promise<boolean>;
1215
- }
1216
-
1217
- // Usage
1218
- const canEdit = await repo.canAccess('update', post);
1219
- if (canEdit) {
1220
- await repo.update(post.id, { title: 'New Title' });
1221
- }
1222
-
1223
- // Bypass RLS (requires system context or bypass role)
1224
- const allPosts = await repo.withoutRLS(async () => {
1225
- return repo.findAll(); // No RLS filtering
1057
+ await withTransaction(executor, async (txCtx) => {
1058
+ // RLS context preserved in transaction
1059
+ const posts = await getPosts(txCtx);
1226
1060
  });
1227
1061
  ```
1228
1062
 
1229
1063
  ---
1230
1064
 
1231
- ## Security Considerations
1232
-
1233
- ### Context Validation
1065
+ ## Common Patterns
1234
1066
 
1235
- Always validate RLS context before use:
1067
+ ### Multi-Tenant Isolation
1236
1068
 
1237
1069
  ```typescript
1238
- import { createRLSContext, RLSContextValidationError } from '@kysera/rls';
1239
-
1240
- try {
1241
- const ctx = createRLSContext({
1242
- auth: {
1243
- userId: user.id, // Required
1244
- roles: user.roles, // Required (array)
1245
- tenantId: user.tenant, // Optional
1246
- },
1247
- });
1248
- } catch (error) {
1249
- if (error instanceof RLSContextValidationError) {
1250
- // Handle invalid context
1251
- }
1252
- }
1253
- ```
1254
-
1255
- ### SQL Injection Prevention
1256
-
1257
- 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
+ });
1258
1079
 
1259
- ```typescript
1260
- // Safe - values are parameterized
1261
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
1080
+ app.use(async (req, res, next) => {
1081
+ const user = await authenticate(req);
1262
1082
 
1263
- // ❌ Never do this - raw SQL from user input
1264
- 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
+ });
1265
1091
  ```
1266
1092
 
1267
- ### Defense in Depth
1268
-
1269
- For maximum security, combine ORM-level RLS with native PostgreSQL RLS:
1093
+ ### Owner-Based Access
1270
1094
 
1271
1095
  ```typescript
1272
- const orm = await createORM(db, [
1273
- rlsPlugin({
1274
- schema: rlsSchema,
1275
- nativeSync: true, // Generate PostgreSQL RLS policies
1276
- }),
1277
- ]);
1278
- ```
1279
-
1280
- ### System User Access
1096
+ const schema = defineRLSSchema<Database>({
1097
+ posts: {
1098
+ policies: [
1099
+ // Public posts visible to all
1100
+ filter('read', ctx => ({ public: true })),
1281
1101
 
1282
- 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),
1283
1104
 
1284
- ```typescript
1285
- // Only for trusted system operations
1286
- await rlsContext.asSystemAsync(async () => {
1287
- 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
+ },
1288
1111
  });
1289
1112
  ```
1290
1113
 
1291
- ### Audit Logging
1292
-
1293
- Enable audit logging in production:
1114
+ ### Role-Based Access Control
1294
1115
 
1295
1116
  ```typescript
1296
- const orm = await createORM(db, [
1297
- rlsPlugin({
1298
- schema: rlsSchema,
1299
- auditDecisions: true, // Log all policy decisions
1300
- onViolation: (violation) => {
1301
- logger.warn('RLS violation', {
1302
- operation: violation.operation,
1303
- table: violation.table,
1304
- userId: violation.userId,
1305
- });
1306
- },
1307
- }),
1308
- ]);
1309
- ```
1117
+ const schema = defineRLSSchema<Database>({
1118
+ posts: {
1119
+ policies: [
1120
+ // Admins can do everything
1121
+ allow('all', ctx => ctx.auth.roles.includes('admin')),
1310
1122
 
1311
- ---
1123
+ // Editors can read and update
1124
+ allow(['read', 'update'], ctx =>
1125
+ ctx.auth.roles.includes('editor')
1126
+ ),
1312
1127
 
1313
- ## Performance Tips
1128
+ // Regular users read only
1129
+ allow('read', ctx => ctx.auth.roles.includes('user')),
1130
+ ],
1131
+ },
1132
+ });
1133
+ ```
1314
1134
 
1315
- ### Index Filter Columns
1135
+ ---
1316
1136
 
1317
- Ensure columns used in filter policies are indexed:
1137
+ ## TypeScript Support
1318
1138
 
1319
- ```sql
1320
- -- tenant_id is commonly used in RLS filters
1321
- CREATE INDEX idx_posts_tenant ON posts (tenant_id);
1322
- CREATE INDEX idx_resources_tenant ON resources (tenant_id);
1323
- ```
1139
+ Full type inference for policies:
1324
1140
 
1325
- ### 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
+ }
1326
1150
 
1327
- 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
+ }),
1328
1159
 
1329
- ```typescript
1330
- filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
1331
- hints: {
1332
- indexColumns: ['tenant_id'],
1333
- 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
+ ],
1334
1166
  },
1335
- })
1167
+ });
1336
1168
  ```
1337
1169
 
1338
- ### Avoid Async Policies for Hot Paths
1170
+ ---
1339
1171
 
1340
- Sync policies are faster than async:
1172
+ ## API Reference
1173
+
1174
+ ### Core Exports
1341
1175
 
1342
1176
  ```typescript
1343
- // Fast - synchronous evaluation
1344
- allow('read', ctx => ctx.auth.userId === ctx.row.owner_id)
1177
+ // Schema definition
1178
+ export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls';
1345
1179
 
1346
- // ⚠️ Slower - async evaluation (use when necessary)
1347
- allow('read', async ctx => {
1348
- const membership = await db.selectFrom('memberships')...
1349
- return membership !== undefined;
1350
- })
1351
- ```
1180
+ // Policy builders
1181
+ export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls';
1352
1182
 
1353
- ---
1183
+ // Plugin
1184
+ export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls';
1354
1185
 
1355
- ## Documentation
1186
+ // Context management
1187
+ export {
1188
+ rlsContext,
1189
+ createRLSContext,
1190
+ withRLSContext,
1191
+ withRLSContextAsync,
1192
+ type RLSContext,
1193
+ } from '@kysera/rls';
1356
1194
 
1357
- 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
+ ```
1358
1206
 
1359
1207
  ---
1360
1208
 
1361
1209
  ## License
1362
1210
 
1363
1211
  MIT
1364
-
1365
- ---
1366
-
1367
- **Built with ❤️ by the Kysera Team**