@kysera/rls 0.5.1

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 ADDED
@@ -0,0 +1,1341 @@
1
+ # @kysera/rls
2
+
3
+ > **Declarative Row-Level Security for Kysera ORM** - Type-safe authorization policies with automatic query transformation and native PostgreSQL RLS support.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@kysera/rls.svg)](https://www.npmjs.com/package/@kysera/rls)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9%2B-blue)](https://www.typescriptlang.org/)
8
+
9
+ ---
10
+
11
+ ## Features
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
23
+
24
+ ---
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ npm install @kysera/rls kysely
30
+ # or
31
+ pnpm add @kysera/rls kysely
32
+ # or
33
+ yarn add @kysera/rls kysely
34
+ ```
35
+
36
+ **Peer Dependencies:**
37
+ - `kysely` >= 0.28.8
38
+ - `@kysera/repository` (workspace package)
39
+ - `@kysera/core` (workspace package)
40
+
41
+ ---
42
+
43
+ ## Quick Start
44
+
45
+ ```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';
50
+
51
+ // Define your database schema
52
+ interface Database {
53
+ posts: {
54
+ id: number;
55
+ title: string;
56
+ content: string;
57
+ author_id: number;
58
+ tenant_id: number;
59
+ status: 'draft' | 'published';
60
+ created_at: Date;
61
+ };
62
+ }
63
+
64
+ // Define RLS policies
65
+ const rlsSchema = defineRLSSchema<Database>({
66
+ posts: {
67
+ policies: [
68
+ // Multi-tenant isolation - all users see only their tenant's data
69
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
70
+
71
+ // Authors can edit their own posts
72
+ allow(['update', 'delete'], ctx =>
73
+ ctx.auth.userId === ctx.row.author_id
74
+ ),
75
+
76
+ // Only published posts are visible to regular users
77
+ filter('read', ctx =>
78
+ ctx.auth.roles.includes('admin') ? {} : { status: 'published' }
79
+ ),
80
+ ],
81
+ defaultDeny: true, // Require explicit allow
82
+ },
83
+ });
84
+
85
+ // Create database connection
86
+ const db = new Kysely<Database>({
87
+ dialect: new PostgresDialect({ pool: new Pool({ /* config */ }) }),
88
+ });
89
+
90
+ // Create ORM with RLS plugin
91
+ const orm = await createORM(db, [
92
+ rlsPlugin({ schema: rlsSchema }),
93
+ ]);
94
+
95
+ // Use within RLS context
96
+ app.use(async (req, res, next) => {
97
+ // Extract user from JWT/session
98
+ const user = await authenticate(req);
99
+
100
+ await rlsContext.runAsync(
101
+ {
102
+ auth: {
103
+ userId: user.id,
104
+ tenantId: user.tenantId,
105
+ roles: user.roles,
106
+ isSystem: false,
107
+ },
108
+ timestamp: new Date(),
109
+ },
110
+ async () => {
111
+ // All queries automatically filtered by tenant_id and policies
112
+ const posts = await orm.posts.findAll(); // Only returns allowed posts
113
+ res.json(posts);
114
+ }
115
+ );
116
+ });
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Core Concepts
122
+
123
+ ### What is Row-Level Security?
124
+
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.
126
+
127
+ **Traditional Approach (Manual):**
128
+ ```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();
136
+ ```
137
+
138
+ **RLS Approach (Automatic):**
139
+ ```typescript
140
+ // ✅ Automatic filtering - declarative, enforced everywhere
141
+ const posts = await orm.posts.findAll(); // Automatically filtered by tenant + status
142
+ ```
143
+
144
+ ### Policy Types
145
+
146
+ #### 1. **`allow`** - Grant Access
147
+
148
+ Grants access when the condition evaluates to `true`. Multiple `allow` policies are combined with OR logic.
149
+
150
+ ```typescript
151
+ // Allow users to read their own posts
152
+ allow('read', ctx => ctx.auth.userId === ctx.row.author_id)
153
+
154
+ // Allow admins to perform any operation
155
+ allow('all', ctx => ctx.auth.roles.includes('admin'))
156
+
157
+ // Allow updates only for draft posts
158
+ allow('update', ctx => ctx.row.status === 'draft')
159
+ ```
160
+
161
+ #### 2. **`deny`** - Block Access
162
+
163
+ Blocks access when the condition evaluates to `true`. Deny policies **override** allow policies and are evaluated first.
164
+
165
+ ```typescript
166
+ // Deny access to banned users
167
+ deny('all', ctx => ctx.auth.attributes?.banned === true)
168
+
169
+ // Prevent deletion of published posts
170
+ deny('delete', ctx => ctx.row.status === 'published')
171
+
172
+ // Block all access to archived records
173
+ deny('all', ctx => ctx.row.archived === true)
174
+ ```
175
+
176
+ #### 3. **`filter`** - Automatic Row Filtering
177
+
178
+ Adds WHERE conditions to SELECT queries automatically. Filter policies return an object with column-value pairs.
179
+
180
+ ```typescript
181
+ // Filter by tenant (multi-tenancy)
182
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
183
+
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
191
+ filter('read', ctx =>
192
+ ctx.auth.roles.includes('admin')
193
+ ? {} // No filtering for admins
194
+ : { status: 'published' } // Only published for others
195
+ )
196
+ ```
197
+
198
+ #### 4. **`validate`** - Mutation Validation
199
+
200
+ Validates data during CREATE and UPDATE operations before execution. Useful for business rules and data integrity.
201
+
202
+ ```typescript
203
+ // Validate user can only create posts in their tenant
204
+ validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
205
+
206
+ // Validate status transitions
207
+ validate('update', ctx => {
208
+ const validTransitions = {
209
+ draft: ['published', 'archived'],
210
+ published: ['archived'],
211
+ archived: [],
212
+ };
213
+ return !ctx.data.status ||
214
+ validTransitions[ctx.row.status]?.includes(ctx.data.status);
215
+ })
216
+
217
+ // Validate email format
218
+ validate('create', ctx => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ctx.data.email))
219
+ ```
220
+
221
+ ### Operations
222
+
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 |
232
+
233
+ ```typescript
234
+ // Single operation
235
+ allow('read', ctx => /* ... */)
236
+
237
+ // Multiple operations
238
+ allow(['read', 'update'], ctx => /* ... */)
239
+
240
+ // All operations
241
+ deny('all', ctx => ctx.auth.attributes?.suspended === true)
242
+ ```
243
+
244
+ ### Policy Evaluation Order
245
+
246
+ Policies are evaluated in a specific order to ensure security:
247
+
248
+ ```
249
+ 1. Check bypass conditions (system user, bypass roles)
250
+ → If bypassed, ALLOW and skip all policies
251
+
252
+ 2. Evaluate DENY policies (sorted by priority, highest first)
253
+ → If ANY deny matches, REJECT immediately
254
+
255
+ 3. Evaluate ALLOW policies (sorted by priority, highest first)
256
+ → If NO allow matches and defaultDeny=true, REJECT
257
+
258
+ 4. Apply FILTER policies (for SELECT queries)
259
+ → Combine all filter conditions with AND logic
260
+
261
+ 5. Apply VALIDATE policies (for CREATE/UPDATE)
262
+ → All validate conditions must pass
263
+
264
+ 6. Execute query
265
+ ```
266
+
267
+ **Priority System:**
268
+ - Higher priority = evaluated first
269
+ - Deny policies default to priority `100`
270
+ - Allow/filter/validate policies default to priority `0`
271
+ - Explicit priority overrides defaults
272
+
273
+ ```typescript
274
+ defineRLSSchema<Database>({
275
+ posts: {
276
+ policies: [
277
+ // Evaluated FIRST (highest priority deny)
278
+ deny('all', ctx => ctx.auth.suspended, { priority: 200 }),
279
+
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
334
+
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
+ });
348
+
349
+ // Admin overrides
350
+ const adminPolicies = defineRLSSchema<Database>({
351
+ posts: {
352
+ policies: [
353
+ allow('all', ctx => ctx.auth.roles.includes('admin')),
354
+ ],
355
+ },
356
+ });
357
+
358
+ // Merged schema applies both
359
+ const schema = mergeRLSSchemas(basePolicies, adminPolicies);
360
+ ```
361
+
362
+ ---
363
+
364
+ ## Policy Builders
365
+
366
+ ### `allow(operation, condition, options?)`
367
+
368
+ Grant access when condition is true.
369
+
370
+ ```typescript
371
+ // Basic allow
372
+ allow('read', ctx => ctx.auth.userId === ctx.row.user_id)
373
+
374
+ // Multiple operations
375
+ allow(['read', 'update'], ctx => ctx.row.owner_id === ctx.auth.userId)
376
+
377
+ // All operations (admin bypass)
378
+ allow('all', ctx => ctx.auth.roles.includes('admin'))
379
+
380
+ // With options
381
+ allow('read', ctx => ctx.auth.verified, {
382
+ name: 'verified-users-only',
383
+ priority: 10,
384
+ hints: { indexColumns: ['verified'], selectivity: 'high' }
385
+ })
386
+
387
+ // Async condition
388
+ allow('update', async ctx => {
389
+ const hasPermission = await checkPermission(ctx.auth.userId, 'posts:edit');
390
+ return hasPermission;
391
+ })
392
+ ```
393
+
394
+ ### `deny(operation, condition?, options?)`
395
+
396
+ Block access when condition is true (overrides allow).
397
+
398
+ ```typescript
399
+ // Basic deny
400
+ deny('delete', ctx => ctx.row.status === 'published')
401
+
402
+ // Deny all operations
403
+ deny('all', ctx => ctx.auth.attributes?.banned === true)
404
+
405
+ // Unconditional deny (no condition)
406
+ deny('all') // Always deny
407
+
408
+ // With high priority
409
+ deny('all', ctx => ctx.auth.suspended, {
410
+ name: 'block-suspended-users',
411
+ priority: 200
412
+ })
413
+ ```
414
+
415
+ ### `filter(operation, condition, options?)`
416
+
417
+ Add WHERE conditions to SELECT queries.
418
+
419
+ ```typescript
420
+ // Simple filter
421
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
422
+
423
+ // Multiple conditions
424
+ filter('read', ctx => ({
425
+ organization_id: ctx.auth.organizationIds?.[0],
426
+ deleted_at: null,
427
+ status: 'active'
428
+ }))
429
+
430
+ // Dynamic filter
431
+ filter('read', ctx => {
432
+ if (ctx.auth.roles.includes('admin')) {
433
+ return {}; // No filtering
434
+ }
435
+ return { status: 'published', public: true };
436
+ })
437
+
438
+ // With options
439
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
440
+ name: 'tenant-isolation',
441
+ priority: 1000, // High priority for tenant isolation
442
+ hints: { indexColumns: ['tenant_id'], selectivity: 'high' }
443
+ })
444
+ ```
445
+
446
+ **Note:** Filter policies only apply to `'read'` operations. Using `'all'` is automatically converted to `'read'`.
447
+
448
+ ### `validate(operation, condition, options?)`
449
+
450
+ Validate mutation data during CREATE/UPDATE.
451
+
452
+ ```typescript
453
+ // Validate create
454
+ validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
455
+
456
+ // Validate update
457
+ validate('update', ctx => {
458
+ // Only allow changing specific fields
459
+ const allowedFields = ['title', 'content', 'tags'];
460
+ return Object.keys(ctx.data).every(key => allowedFields.includes(key));
461
+ })
462
+
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
+ })
488
+ ```
489
+
490
+ **Note:** Validate policies apply to `'create'` and `'update'` operations. Using `'all'` applies to both.
491
+
492
+ ### Policy Options
493
+
494
+ All policy builders accept an optional `options` parameter:
495
+
496
+ ```typescript
497
+ interface PolicyOptions {
498
+ /** Policy name for debugging and identification */
499
+ name?: string;
500
+
501
+ /** Priority (higher runs first, deny defaults to 100) */
502
+ priority?: number;
503
+
504
+ /** Performance optimization hints */
505
+ hints?: {
506
+ /** Columns that should be indexed */
507
+ indexColumns?: string[];
508
+
509
+ /** Expected selectivity (high = filters many rows) */
510
+ selectivity?: 'high' | 'medium' | 'low';
511
+
512
+ /** Whether policy is leakproof (safe to execute early) */
513
+ leakproof?: boolean;
514
+
515
+ /** Whether policy result is stable for same inputs */
516
+ stable?: boolean;
517
+ };
518
+ }
519
+ ```
520
+
521
+ ---
522
+
523
+ ## Context Management
524
+
525
+ ### RLSContext Interface
526
+
527
+ The RLS context contains all information needed for policy evaluation.
528
+
529
+ ```typescript
530
+ interface RLSContext<TUser = unknown, TMeta = unknown> {
531
+ /** Authentication context (required) */
532
+ 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) */
543
+ organizationIds?: (string | number)[];
544
+
545
+ /** Granular permissions (optional) */
546
+ permissions?: string[];
547
+
548
+ /** Custom user attributes (optional) */
549
+ attributes?: Record<string, unknown>;
550
+
551
+ /** Full user object (optional) */
552
+ user?: TUser;
553
+
554
+ /** System/admin bypass flag (default: false) */
555
+ isSystem?: boolean;
556
+ };
557
+
558
+ /** Request context (optional) */
559
+ request?: {
560
+ requestId?: string;
561
+ ipAddress?: string;
562
+ userAgent?: string;
563
+ timestamp: Date;
564
+ headers?: Record<string, string>;
565
+ };
566
+
567
+ /** Custom metadata (optional) */
568
+ meta?: TMeta;
569
+
570
+ /** Context creation timestamp */
571
+ timestamp: Date;
572
+ }
573
+ ```
574
+
575
+ ### `rlsContext.runAsync(context, fn)`
576
+
577
+ Run a function within an RLS context (async).
578
+
579
+ ```typescript
580
+ import { rlsContext } from '@kysera/rls';
581
+
582
+ await rlsContext.runAsync(
583
+ {
584
+ auth: {
585
+ userId: 123,
586
+ roles: ['user'],
587
+ tenantId: 'acme-corp',
588
+ isSystem: false,
589
+ },
590
+ timestamp: new Date(),
591
+ },
592
+ async () => {
593
+ // All queries in this scope use the RLS context
594
+ const posts = await orm.posts.findAll();
595
+ await orm.posts.create({ title: 'New Post', /* ... */ });
596
+ }
597
+ );
598
+ ```
599
+
600
+ ### `rlsContext.run(context, fn)`
601
+
602
+ Run a function within an RLS context (sync).
603
+
604
+ ```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
+ );
615
+ ```
616
+
617
+ ### `createRLSContext(options)`
618
+
619
+ Create an RLS context object with validation.
620
+
621
+ ```typescript
622
+ import { createRLSContext } from '@kysera/rls';
623
+
624
+ const ctx = createRLSContext({
625
+ userId: 123,
626
+ roles: ['user', 'editor'],
627
+ tenantId: 'acme-corp',
628
+ // Optional fields
629
+ organizationIds: ['org-1'],
630
+ permissions: ['posts:read', 'posts:write'],
631
+ isSystem: false,
632
+ });
633
+
634
+ // Use with runAsync
635
+ await rlsContext.runAsync(ctx, async () => {
636
+ // ...
637
+ });
638
+ ```
639
+
640
+ ### `withRLSContext(context, fn)`
641
+
642
+ Helper for async context execution (alternative to `runAsync`).
643
+
644
+ ```typescript
645
+ import { withRLSContext } from '@kysera/rls';
646
+
647
+ const result = await withRLSContext(
648
+ {
649
+ auth: { userId: 123, roles: ['user'], isSystem: false },
650
+ timestamp: new Date(),
651
+ },
652
+ async () => {
653
+ return await orm.posts.findAll();
654
+ }
655
+ );
656
+ ```
657
+
658
+ ### Context Helpers
659
+
660
+ ```typescript
661
+ // Get current context (throws if not set)
662
+ const ctx = rlsContext.getContext();
663
+
664
+ // Get current context or null (safe)
665
+ const ctx = rlsContext.getContextOrNull();
666
+
667
+ // Check if context exists
668
+ if (rlsContext.hasContext()) {
669
+ // Context is available
670
+ }
671
+
672
+ // Run as system user (bypass RLS)
673
+ await rlsContext.asSystemAsync(async () => {
674
+ // All queries bypass RLS policies
675
+ const allPosts = await orm.posts.findAll();
676
+ });
677
+ ```
678
+
679
+ ---
680
+
681
+ ## Common Patterns
682
+
683
+ ### Multi-Tenant Isolation
684
+
685
+ ```typescript
686
+ const schema = defineRLSSchema<Database>({
687
+ // Apply tenant isolation to all tables
688
+ posts: {
689
+ policies: [
690
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
691
+ validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
692
+ validate('update', ctx => !ctx.data.tenant_id ||
693
+ ctx.data.tenant_id === ctx.auth.tenantId),
694
+ ],
695
+ defaultDeny: true,
696
+ },
697
+
698
+ comments: {
699
+ policies: [
700
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
701
+ validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
702
+ ],
703
+ defaultDeny: true,
704
+ },
705
+ });
706
+
707
+ // Usage
708
+ app.use(async (req, res, next) => {
709
+ const user = await authenticate(req);
710
+
711
+ await rlsContext.runAsync(
712
+ {
713
+ auth: {
714
+ userId: user.id,
715
+ tenantId: user.tenant_id, // Tenant from JWT/session
716
+ roles: user.roles,
717
+ isSystem: false,
718
+ },
719
+ timestamp: new Date(),
720
+ },
721
+ async () => {
722
+ // Automatically filtered by tenant_id
723
+ const posts = await orm.posts.findAll();
724
+ res.json(posts);
725
+ }
726
+ );
727
+ });
728
+ ```
729
+
730
+ ### Owner-Based Access
731
+
732
+ ```typescript
733
+ const schema = defineRLSSchema<Database>({
734
+ posts: {
735
+ policies: [
736
+ // Users can read all public posts
737
+ filter('read', ctx => ({ public: true })),
738
+
739
+ // Users can read their own posts (public or private)
740
+ allow('read', ctx => ctx.auth.userId === ctx.row.author_id),
741
+
742
+ // Users can update/delete only their own posts
743
+ allow(['update', 'delete'], ctx =>
744
+ ctx.auth.userId === ctx.row.author_id
745
+ ),
746
+
747
+ // Users can create posts
748
+ allow('create', ctx => true),
749
+
750
+ // Set author_id automatically
751
+ validate('create', ctx => ctx.data.author_id === ctx.auth.userId),
752
+ ],
753
+ defaultDeny: true,
754
+ },
755
+ });
756
+ ```
757
+
758
+ ### Role-Based Access Control (RBAC)
759
+
760
+ ```typescript
761
+ const schema = defineRLSSchema<Database>({
762
+ posts: {
763
+ policies: [
764
+ // Admins can do everything
765
+ allow('all', ctx => ctx.auth.roles.includes('admin')),
766
+
767
+ // Editors can read and update
768
+ allow(['read', 'update'], ctx =>
769
+ ctx.auth.roles.includes('editor')
770
+ ),
771
+
772
+ // Authors can create and edit their own
773
+ allow(['read', 'update', 'delete'], ctx =>
774
+ ctx.auth.roles.includes('author') &&
775
+ ctx.auth.userId === ctx.row.author_id
776
+ ),
777
+
778
+ // Regular users can only read published posts
779
+ allow('read', ctx =>
780
+ ctx.auth.roles.includes('user') &&
781
+ ctx.row.status === 'published'
782
+ ),
783
+ ],
784
+ defaultDeny: true,
785
+ },
786
+ });
787
+ ```
788
+
789
+ ### Status-Based Restrictions
790
+
791
+ ```typescript
792
+ const schema = defineRLSSchema<Database>({
793
+ posts: {
794
+ policies: [
795
+ // Can't delete published posts
796
+ deny('delete', ctx => ctx.row.status === 'published'),
797
+
798
+ // Can only update drafts and pending
799
+ allow('update', ctx =>
800
+ ['draft', 'pending'].includes(ctx.row.status)
801
+ ),
802
+
803
+ // Validate status transitions
804
+ validate('update', ctx => {
805
+ if (!ctx.data.status) return true;
806
+
807
+ const transitions = {
808
+ draft: ['pending', 'published'],
809
+ pending: ['published', 'draft'],
810
+ published: ['archived'],
811
+ archived: [],
812
+ };
813
+
814
+ return transitions[ctx.row.status]?.includes(ctx.data.status) ?? false;
815
+ }),
816
+ ],
817
+ },
818
+ });
819
+ ```
820
+
821
+ ---
822
+
823
+ ## Native PostgreSQL RLS
824
+
825
+ Generate native database-level RLS policies for defense-in-depth security.
826
+
827
+ ### `PostgresRLSGenerator`
828
+
829
+ Generate PostgreSQL `CREATE POLICY` statements from your RLS schema.
830
+
831
+ ```typescript
832
+ import { PostgresRLSGenerator } from '@kysera/rls/native';
833
+
834
+ const generator = new PostgresRLSGenerator(rlsSchema, {
835
+ contextFunctions: {
836
+ // Define SQL functions to access context
837
+ userId: 'current_setting(\'app.user_id\')::integer',
838
+ tenantId: 'current_setting(\'app.tenant_id\')::uuid',
839
+ roles: 'current_setting(\'app.roles\')::text[]',
840
+ },
841
+ });
842
+
843
+ // Generate policies for a table
844
+ const sql = generator.generatePolicies('posts');
845
+ console.log(sql);
846
+
847
+ /*
848
+ Output:
849
+ ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
850
+
851
+ CREATE POLICY tenant_isolation ON posts
852
+ FOR ALL
853
+ USING (tenant_id = current_setting('app.tenant_id')::uuid);
854
+
855
+ CREATE POLICY author_access ON posts
856
+ FOR UPDATE
857
+ USING (author_id = current_setting('app.user_id')::integer);
858
+ */
859
+ ```
860
+
861
+ ### `RLSMigrationGenerator`
862
+
863
+ Generate migration files for PostgreSQL RLS policies.
864
+
865
+ ```typescript
866
+ import { RLSMigrationGenerator } from '@kysera/rls/native';
867
+
868
+ const migrationGen = new RLSMigrationGenerator(rlsSchema, {
869
+ contextFunctions: {
870
+ userId: 'current_setting(\'app.user_id\')::integer',
871
+ tenantId: 'current_setting(\'app.tenant_id\')::uuid',
872
+ },
873
+ migrationPath: './migrations',
874
+ timestamp: true,
875
+ });
876
+
877
+ // Generate migration
878
+ const { up, down } = migrationGen.generateMigration();
879
+
880
+ console.log('--- UP ---');
881
+ console.log(up);
882
+ console.log('--- DOWN ---');
883
+ console.log(down);
884
+ ```
885
+
886
+ ### `syncContextToPostgres`
887
+
888
+ Sync RLS context to PostgreSQL session variables.
889
+
890
+ ```typescript
891
+ import { syncContextToPostgres } from '@kysera/rls/native';
892
+ import { db } from './database';
893
+
894
+ await rlsContext.runAsync(
895
+ {
896
+ auth: { userId: 123, tenantId: 'acme', roles: ['user'], isSystem: false },
897
+ timestamp: new Date(),
898
+ },
899
+ async () => {
900
+ // Sync context to PostgreSQL session
901
+ await syncContextToPostgres(db);
902
+
903
+ // Now PostgreSQL policies can access:
904
+ // current_setting('app.user_id')::integer = 123
905
+ // current_setting('app.tenant_id')::text = 'acme'
906
+ // current_setting('app.roles')::text[] = '{user}'
907
+
908
+ // Execute raw SQL with native RLS
909
+ await db.executeQuery(sql`SELECT * FROM posts`);
910
+ }
911
+ );
912
+ ```
913
+
914
+ ---
915
+
916
+ ## Error Handling
917
+
918
+ ### Error Types
919
+
920
+ ```typescript
921
+ import {
922
+ RLSError,
923
+ RLSContextError,
924
+ RLSPolicyViolation,
925
+ RLSSchemaError,
926
+ RLSContextValidationError,
927
+ RLSErrorCodes,
928
+ } from '@kysera/rls';
929
+ ```
930
+
931
+ #### `RLSContextError`
932
+
933
+ Thrown when RLS context is missing or not set.
934
+
935
+ ```typescript
936
+ try {
937
+ // No RLS context set
938
+ await orm.posts.findAll();
939
+ } catch (error) {
940
+ if (error instanceof RLSContextError) {
941
+ console.error('RLS context required:', error.message);
942
+ // error.code === 'RLS_CONTEXT_MISSING'
943
+ }
944
+ }
945
+ ```
946
+
947
+ #### `RLSPolicyViolation`
948
+
949
+ Thrown when a database operation is denied by RLS policies.
950
+
951
+ ```typescript
952
+ try {
953
+ // User tries to update a post they don't own
954
+ await orm.posts.update(1, { title: 'New Title' });
955
+ } catch (error) {
956
+ 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'
962
+ });
963
+ }
964
+ }
965
+ ```
966
+
967
+ ### Handling Violations
968
+
969
+ ```typescript
970
+ import { rlsPlugin } from '@kysera/rls';
971
+
972
+ const orm = await createORM(db, [
973
+ rlsPlugin({
974
+ schema: rlsSchema,
975
+
976
+ // Custom violation handler
977
+ onViolation: (violation) => {
978
+ console.error('RLS Violation:', {
979
+ operation: violation.operation,
980
+ table: violation.table,
981
+ reason: violation.reason,
982
+ policyName: violation.policyName,
983
+ });
984
+
985
+ // Log to audit system
986
+ auditLog.record({
987
+ type: 'rls_violation',
988
+ operation: violation.operation,
989
+ table: violation.table,
990
+ timestamp: new Date(),
991
+ });
992
+ },
993
+
994
+ // Enable audit logging
995
+ auditDecisions: true,
996
+ }),
997
+ ]);
998
+ ```
999
+
1000
+ ---
1001
+
1002
+ ## TypeScript Support
1003
+
1004
+ ### Full Type Inference
1005
+
1006
+ ```typescript
1007
+ // Database schema
1008
+ interface Database {
1009
+ posts: {
1010
+ id: number;
1011
+ title: string;
1012
+ author_id: number;
1013
+ tenant_id: string;
1014
+ };
1015
+ }
1016
+
1017
+ // Type-safe policy definition
1018
+ const schema = defineRLSSchema<Database>({
1019
+ posts: {
1020
+ policies: [
1021
+ // ctx.row is typed as Database['posts']
1022
+ allow('read', ctx => {
1023
+ const post = ctx.row; // Type: Database['posts']
1024
+ const userId = ctx.auth.userId; // Type: string | number
1025
+
1026
+ return post.author_id === userId;
1027
+ }),
1028
+
1029
+ // ctx.data is typed as Partial<Database['posts']>
1030
+ validate('update', ctx => {
1031
+ const data = ctx.data; // Type: Partial<Database['posts']>
1032
+ const title = data.title; // Type: string | undefined
1033
+
1034
+ return !title || title.length > 0;
1035
+ }),
1036
+ ],
1037
+ },
1038
+ });
1039
+ ```
1040
+
1041
+ ---
1042
+
1043
+ ## Testing
1044
+
1045
+ ### Unit Testing Policies
1046
+
1047
+ ```typescript
1048
+ import { describe, it, expect } from 'vitest';
1049
+ import { allow, filter, validate } from '@kysera/rls';
1050
+
1051
+ describe('Post Policies', () => {
1052
+ it('should allow owner to update post', () => {
1053
+ const policy = allow('update', ctx =>
1054
+ ctx.auth.userId === ctx.row.author_id
1055
+ );
1056
+
1057
+ const context = {
1058
+ auth: { userId: 123, roles: [], isSystem: false },
1059
+ row: { author_id: 123 },
1060
+ };
1061
+
1062
+ const result = policy.condition(context as any);
1063
+ expect(result).toBe(true);
1064
+ });
1065
+
1066
+ it('should filter posts by tenant', () => {
1067
+ const policy = filter('read', ctx => ({
1068
+ tenant_id: ctx.auth.tenantId
1069
+ }));
1070
+
1071
+ const context = {
1072
+ auth: { userId: 123, tenantId: 'acme', roles: [], isSystem: false },
1073
+ };
1074
+
1075
+ const result = policy.condition(context as any);
1076
+ expect(result).toEqual({ tenant_id: 'acme' });
1077
+ });
1078
+ });
1079
+ ```
1080
+
1081
+ ### Integration Testing
1082
+
1083
+ ```typescript
1084
+ import { describe, it, beforeEach, expect } from 'vitest';
1085
+ import { createORM } from '@kysera/repository';
1086
+ import { rlsPlugin, rlsContext, defineRLSSchema } from '@kysera/rls';
1087
+
1088
+ describe('RLS Integration', () => {
1089
+ let orm: ReturnType<typeof createORM>;
1090
+
1091
+ beforeEach(async () => {
1092
+ const schema = defineRLSSchema<Database>({
1093
+ posts: {
1094
+ policies: [
1095
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
1096
+ ],
1097
+ },
1098
+ });
1099
+
1100
+ orm = await createORM(db, [rlsPlugin({ schema })]);
1101
+ });
1102
+
1103
+ it('should filter posts by tenant', async () => {
1104
+ await rlsContext.runAsync(
1105
+ {
1106
+ auth: {
1107
+ userId: 1,
1108
+ tenantId: 'tenant-1',
1109
+ roles: ['user'],
1110
+ isSystem: false,
1111
+ },
1112
+ timestamp: new Date(),
1113
+ },
1114
+ async () => {
1115
+ const posts = await orm.posts.findAll();
1116
+
1117
+ // All posts should belong to tenant-1
1118
+ expect(posts.every(p => p.tenant_id === 'tenant-1')).toBe(true);
1119
+ }
1120
+ );
1121
+ });
1122
+ });
1123
+ ```
1124
+
1125
+ ---
1126
+
1127
+ ## API Reference
1128
+
1129
+ ### Core Exports
1130
+
1131
+ ```typescript
1132
+ // Schema definition
1133
+ export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls';
1134
+
1135
+ // Policy builders
1136
+ export { allow, deny, filter, validate } from '@kysera/rls';
1137
+
1138
+ // Plugin
1139
+ export { rlsPlugin } from '@kysera/rls';
1140
+ export type { RLSPluginOptions } from '@kysera/rls';
1141
+
1142
+ // Context management
1143
+ export {
1144
+ rlsContext,
1145
+ createRLSContext,
1146
+ withRLSContext,
1147
+ withRLSContextAsync,
1148
+ } from '@kysera/rls';
1149
+
1150
+ // Errors
1151
+ export {
1152
+ RLSError,
1153
+ RLSContextError,
1154
+ RLSPolicyViolation,
1155
+ RLSSchemaError,
1156
+ RLSContextValidationError,
1157
+ RLSErrorCodes,
1158
+ } from '@kysera/rls';
1159
+ ```
1160
+
1161
+ ### Native PostgreSQL Exports
1162
+
1163
+ ```typescript
1164
+ // Import from @kysera/rls/native
1165
+ export {
1166
+ PostgresRLSGenerator,
1167
+ syncContextToPostgres,
1168
+ clearPostgresContext,
1169
+ RLSMigrationGenerator,
1170
+ } from '@kysera/rls/native';
1171
+ ```
1172
+
1173
+ ### Repository Extensions
1174
+
1175
+ When using the RLS plugin, repositories are extended with:
1176
+
1177
+ ```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
1200
+ });
1201
+ ```
1202
+
1203
+ ---
1204
+
1205
+ ## Security Considerations
1206
+
1207
+ ### Context Validation
1208
+
1209
+ Always validate RLS context before use:
1210
+
1211
+ ```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:
1232
+
1233
+ ```typescript
1234
+ // ✅ Safe - values are parameterized
1235
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
1236
+
1237
+ // ❌ Never do this - raw SQL from user input
1238
+ filter('read', ctx => sql.raw(`tenant_id = '${userInput}'`))
1239
+ ```
1240
+
1241
+ ### Defense in Depth
1242
+
1243
+ For maximum security, combine ORM-level RLS with native PostgreSQL RLS:
1244
+
1245
+ ```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
1255
+
1256
+ The `isSystem: true` flag bypasses all RLS checks. Use sparingly:
1257
+
1258
+ ```typescript
1259
+ // Only for trusted system operations
1260
+ await rlsContext.asSystemAsync(async () => {
1261
+ await db.selectFrom('audit_logs').selectAll().execute();
1262
+ });
1263
+ ```
1264
+
1265
+ ### Audit Logging
1266
+
1267
+ Enable audit logging in production:
1268
+
1269
+ ```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
+ ```
1284
+
1285
+ ---
1286
+
1287
+ ## Performance Tips
1288
+
1289
+ ### Index Filter Columns
1290
+
1291
+ Ensure columns used in filter policies are indexed:
1292
+
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
+ ```
1298
+
1299
+ ### Use Hints for Native RLS
1300
+
1301
+ When generating native PostgreSQL policies, use hints:
1302
+
1303
+ ```typescript
1304
+ filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
1305
+ hints: {
1306
+ indexColumns: ['tenant_id'],
1307
+ selectivity: 'high', // Many rows per tenant
1308
+ },
1309
+ })
1310
+ ```
1311
+
1312
+ ### Avoid Async Policies for Hot Paths
1313
+
1314
+ Sync policies are faster than async:
1315
+
1316
+ ```typescript
1317
+ // ✅ Fast - synchronous evaluation
1318
+ allow('read', ctx => ctx.auth.userId === ctx.row.owner_id)
1319
+
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
+ ```
1326
+
1327
+ ---
1328
+
1329
+ ## Documentation
1330
+
1331
+ See [kysera-rls-spec.md](kysera-rls-spec.md) for detailed specification and architecture.
1332
+
1333
+ ---
1334
+
1335
+ ## License
1336
+
1337
+ MIT
1338
+
1339
+ ---
1340
+
1341
+ **Built with ❤️ by the Kysera Team**