@kysera/rls 0.7.2 → 0.7.4
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 +390 -276
- package/dist/index.d.ts +95 -19
- package/dist/index.js +219 -130
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/native/index.js +3 -14
- package/dist/native/index.js.map +1 -1
- package/dist/{types-6eCXh_Jd.d.ts → types-Dowjd6zG.d.ts} +3 -3
- package/package.json +20 -11
- package/src/context/index.ts +4 -4
- package/src/context/manager.ts +45 -45
- package/src/context/storage.ts +3 -3
- package/src/context/types.ts +1 -5
- package/src/errors.ts +62 -77
- package/src/index.ts +13 -13
- package/src/native/README.md +49 -46
- package/src/native/index.ts +3 -6
- package/src/native/migration.ts +29 -27
- package/src/native/postgres.ts +63 -74
- package/src/plugin.ts +306 -159
- package/src/policy/builder.ts +46 -33
- package/src/policy/index.ts +4 -4
- package/src/policy/registry.ts +100 -105
- package/src/policy/schema.ts +58 -71
- package/src/policy/types.ts +58 -58
- package/src/transformer/index.ts +2 -2
- package/src/transformer/mutation.ts +95 -98
- package/src/transformer/select.ts +59 -43
- package/src/utils/helpers.ts +57 -50
- package/src/utils/index.ts +13 -2
- package/src/utils/type-utils.ts +155 -0
- package/src/version.ts +7 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @kysera/rls
|
|
2
2
|
|
|
3
|
-
> **Row-Level Security Plugin for Kysera** - Declarative authorization policies with automatic query transformation and AsyncLocalStorage-based context management.
|
|
3
|
+
> **Row-Level Security Plugin for Kysera (v0.7.3)** - Declarative authorization policies with automatic query transformation through @kysera/executor's Unified Execution Layer and AsyncLocalStorage-based context management.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@kysera/rls)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -19,13 +19,14 @@ RLS controls access to individual rows in database tables based on user context.
|
|
|
19
19
|
**Key Features:**
|
|
20
20
|
|
|
21
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
|
|
22
|
+
- **Automatic Query Transformation** - SELECT queries are filtered automatically via `interceptQuery` hook
|
|
23
|
+
- **Repository Extensions** - Wraps mutation methods via `extendRepository` hook for policy enforcement
|
|
24
|
+
- **Type-Safe Context** - Full TypeScript inference with reduced `any` usage through type utilities
|
|
25
25
|
- **Multi-Tenant Isolation** - Built-in patterns for SaaS tenant separation
|
|
26
|
-
- **Plugin Architecture** - Works with both Repository and DAL patterns via
|
|
26
|
+
- **Plugin Architecture** - Works with both Repository and DAL patterns via @kysera/executor's Unified Execution Layer
|
|
27
27
|
- **Zero Runtime Overhead** - Policies compiled at initialization
|
|
28
28
|
- **AsyncLocalStorage Context** - Request-scoped context without prop drilling
|
|
29
|
+
- **Optional Dependency** - Listed in peerDependencies with `optional: true` for @kysera/repository
|
|
29
30
|
|
|
30
31
|
---
|
|
31
32
|
|
|
@@ -40,10 +41,13 @@ yarn add @kysera/rls kysely
|
|
|
40
41
|
```
|
|
41
42
|
|
|
42
43
|
**Dependencies:**
|
|
44
|
+
|
|
43
45
|
- `kysely` >= 0.28.8 (peer dependency)
|
|
44
|
-
- `@kysera/core` - Core utilities (auto-installed)
|
|
45
|
-
- `@kysera/executor` -
|
|
46
|
-
- `@kysera/repository` or `@kysera/dal` - For Repository or DAL patterns (install as needed)
|
|
46
|
+
- `@kysera/core` >= 0.7.0 - Core utilities (auto-installed)
|
|
47
|
+
- `@kysera/executor` >= 0.7.0 - Unified Execution Layer (auto-installed)
|
|
48
|
+
- `@kysera/repository` >= 0.7.0 or `@kysera/dal` >= 0.7.0 - For Repository or DAL patterns (install as needed)
|
|
49
|
+
|
|
50
|
+
**Note:** @kysera/rls is listed in peerDependencies with `optional: true` for @kysera/repository, allowing flexible installation based on your needs.
|
|
47
51
|
|
|
48
52
|
---
|
|
49
53
|
|
|
@@ -52,17 +56,17 @@ yarn add @kysera/rls kysely
|
|
|
52
56
|
### 1. Define RLS Schema
|
|
53
57
|
|
|
54
58
|
```typescript
|
|
55
|
-
import { defineRLSSchema, filter, allow, validate } from '@kysera/rls'
|
|
59
|
+
import { defineRLSSchema, filter, allow, validate } from '@kysera/rls'
|
|
56
60
|
|
|
57
61
|
interface Database {
|
|
58
62
|
posts: {
|
|
59
|
-
id: number
|
|
60
|
-
title: string
|
|
61
|
-
content: string
|
|
62
|
-
author_id: number
|
|
63
|
-
tenant_id: number
|
|
64
|
-
status: 'draft' | 'published'
|
|
65
|
-
}
|
|
63
|
+
id: number
|
|
64
|
+
title: string
|
|
65
|
+
content: string
|
|
66
|
+
author_id: number
|
|
67
|
+
tenant_id: number
|
|
68
|
+
status: 'draft' | 'published'
|
|
69
|
+
}
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
const rlsSchema = defineRLSSchema<Database>({
|
|
@@ -72,44 +76,47 @@ const rlsSchema = defineRLSSchema<Database>({
|
|
|
72
76
|
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
|
|
73
77
|
|
|
74
78
|
// Authors can edit their own posts
|
|
75
|
-
allow(['update', 'delete'], ctx =>
|
|
76
|
-
ctx.auth.userId === ctx.row.author_id
|
|
77
|
-
),
|
|
79
|
+
allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row.author_id),
|
|
78
80
|
|
|
79
81
|
// Validate new posts belong to user's tenant
|
|
80
|
-
validate('create', ctx =>
|
|
81
|
-
ctx.data.tenant_id === ctx.auth.tenantId
|
|
82
|
-
),
|
|
82
|
+
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
83
83
|
],
|
|
84
|
-
defaultDeny: true
|
|
85
|
-
}
|
|
86
|
-
})
|
|
84
|
+
defaultDeny: true // Require explicit allow
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
### 2.
|
|
89
|
+
### 2. Register Plugin with Unified Execution Layer
|
|
90
90
|
|
|
91
91
|
```typescript
|
|
92
|
-
import {
|
|
93
|
-
import {
|
|
94
|
-
import {
|
|
92
|
+
import { createExecutor } from '@kysera/executor'
|
|
93
|
+
import { createORM } from '@kysera/repository'
|
|
94
|
+
import { rlsPlugin, rlsContext } from '@kysera/rls'
|
|
95
|
+
import { Kysely, PostgresDialect } from 'kysely'
|
|
95
96
|
|
|
96
97
|
const db = new Kysely<Database>({
|
|
97
|
-
dialect: new PostgresDialect({
|
|
98
|
-
|
|
98
|
+
dialect: new PostgresDialect({
|
|
99
|
+
/* config */
|
|
100
|
+
})
|
|
101
|
+
})
|
|
99
102
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
// Step 1: Create executor with RLS plugin
|
|
104
|
+
const executor = await createExecutor(db, [
|
|
105
|
+
rlsPlugin({ schema: rlsSchema })
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
// Step 2: Create ORM with plugin-enabled executor
|
|
109
|
+
const orm = await createORM(executor, [])
|
|
103
110
|
```
|
|
104
111
|
|
|
105
112
|
### 3. Execute Queries within RLS Context
|
|
106
113
|
|
|
107
114
|
```typescript
|
|
108
|
-
import { rlsContext } from '@kysera/rls'
|
|
115
|
+
import { rlsContext } from '@kysera/rls'
|
|
109
116
|
|
|
110
117
|
// In your request handler
|
|
111
118
|
app.use(async (req, res, next) => {
|
|
112
|
-
const user = await authenticate(req)
|
|
119
|
+
const user = await authenticate(req)
|
|
113
120
|
|
|
114
121
|
await rlsContext.runAsync(
|
|
115
122
|
{
|
|
@@ -117,51 +124,63 @@ app.use(async (req, res, next) => {
|
|
|
117
124
|
userId: user.id,
|
|
118
125
|
tenantId: user.tenantId,
|
|
119
126
|
roles: user.roles,
|
|
120
|
-
isSystem: false
|
|
127
|
+
isSystem: false
|
|
121
128
|
},
|
|
122
|
-
timestamp: new Date()
|
|
129
|
+
timestamp: new Date()
|
|
123
130
|
},
|
|
124
131
|
async () => {
|
|
125
132
|
// All queries automatically filtered by policies
|
|
126
|
-
const posts = await orm.posts.findAll()
|
|
127
|
-
res.json(posts)
|
|
133
|
+
const posts = await orm.posts.findAll()
|
|
134
|
+
res.json(posts)
|
|
128
135
|
}
|
|
129
|
-
)
|
|
130
|
-
})
|
|
136
|
+
)
|
|
137
|
+
})
|
|
131
138
|
```
|
|
132
139
|
|
|
133
140
|
---
|
|
134
141
|
|
|
135
142
|
## Plugin Architecture
|
|
136
143
|
|
|
137
|
-
### Integration with @kysera/executor
|
|
144
|
+
### Integration with @kysera/executor's Unified Execution Layer
|
|
138
145
|
|
|
139
|
-
The RLS plugin is built on
|
|
146
|
+
The RLS plugin is built on @kysera/executor's Unified Execution Layer, which provides seamless plugin support that works with both Repository and DAL patterns.
|
|
140
147
|
|
|
141
148
|
**Plugin Metadata:**
|
|
142
149
|
|
|
143
150
|
```typescript
|
|
144
151
|
{
|
|
145
152
|
name: '@kysera/rls',
|
|
146
|
-
version: '0.7.
|
|
153
|
+
version: '0.7.3',
|
|
147
154
|
priority: 50, // Runs after soft-delete (0), before audit (100)
|
|
148
155
|
dependencies: [],
|
|
149
156
|
}
|
|
150
157
|
```
|
|
151
158
|
|
|
159
|
+
**Type Utilities:**
|
|
160
|
+
|
|
161
|
+
The plugin uses type utilities to reduce `any` usage and improve type safety:
|
|
162
|
+
- Conditional types for precise type inference
|
|
163
|
+
- Generic constraints for database schemas
|
|
164
|
+
- Utility types for context and policy definitions
|
|
165
|
+
|
|
152
166
|
### How It Works
|
|
153
167
|
|
|
154
|
-
The RLS plugin implements two key hooks from
|
|
168
|
+
The RLS plugin implements two key hooks from @kysera/executor's plugin system:
|
|
155
169
|
|
|
156
170
|
#### 1. `interceptQuery` - Query Filtering (SELECT)
|
|
157
171
|
|
|
158
|
-
|
|
172
|
+
Registered via `createExecutor()`, the `interceptQuery` hook intercepts all query builder operations to apply RLS filtering:
|
|
159
173
|
|
|
160
174
|
```typescript
|
|
161
|
-
//
|
|
162
|
-
const
|
|
175
|
+
// Step 1: Register plugin with Unified Execution Layer
|
|
176
|
+
const executor = await createExecutor(db, [
|
|
177
|
+
rlsPlugin({ schema: rlsSchema })
|
|
178
|
+
])
|
|
179
|
+
|
|
180
|
+
// Step 2: Execute a SELECT query
|
|
181
|
+
const posts = await orm.posts.findAll()
|
|
163
182
|
|
|
164
|
-
// The plugin interceptQuery hook:
|
|
183
|
+
// Step 3: The plugin interceptQuery hook:
|
|
165
184
|
// 1. Checks for RLS context (rlsContext.getContextOrNull())
|
|
166
185
|
// 2. Checks if system user (ctx.auth.isSystem) or bypass role
|
|
167
186
|
// 3. Applies filter policies as WHERE conditions via SelectTransformer
|
|
@@ -170,19 +189,23 @@ const posts = await orm.posts.findAll();
|
|
|
170
189
|
```
|
|
171
190
|
|
|
172
191
|
**Key behavior:**
|
|
192
|
+
|
|
173
193
|
- SELECT operations: Policies are applied immediately as WHERE clauses
|
|
174
194
|
- INSERT/UPDATE/DELETE: Marked for validation (actual enforcement in `extendRepository`)
|
|
175
195
|
- Skip conditions: `skipTables`, `metadata['skipRLS']`, `requireContext`, system user, bypass roles
|
|
176
196
|
|
|
177
197
|
#### 2. `extendRepository` - Mutation Enforcement (CREATE/UPDATE/DELETE)
|
|
178
198
|
|
|
179
|
-
|
|
199
|
+
Registered via `createExecutor()`, the `extendRepository` hook wraps repository mutation methods to enforce RLS policies:
|
|
180
200
|
|
|
181
201
|
```typescript
|
|
182
|
-
//
|
|
183
|
-
await
|
|
202
|
+
// Step 1: Plugin registered with Unified Execution Layer
|
|
203
|
+
const executor = await createExecutor(db, [rlsPlugin({ schema: rlsSchema })])
|
|
184
204
|
|
|
185
|
-
//
|
|
205
|
+
// Step 2: Call a mutation
|
|
206
|
+
await repo.update(postId, { title: 'New Title' })
|
|
207
|
+
|
|
208
|
+
// Step 3: The plugin extendRepository hook:
|
|
186
209
|
// 1. Wraps create/update/delete methods
|
|
187
210
|
// 2. Fetches existing row using getRawDb() (bypasses RLS filtering)
|
|
188
211
|
// 3. Evaluates allow/deny policies via MutationGuard
|
|
@@ -191,7 +214,7 @@ await repo.update(postId, { title: 'New Title' });
|
|
|
191
214
|
// 6. Adds withoutRLS() and canAccess() utility methods
|
|
192
215
|
```
|
|
193
216
|
|
|
194
|
-
**Why use `getRawDb()`?** To prevent infinite recursion - we need to fetch the existing row without triggering RLS filtering. The `getRawDb()` function from
|
|
217
|
+
**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
218
|
|
|
196
219
|
---
|
|
197
220
|
|
|
@@ -247,7 +270,7 @@ filter('read', ctx =>
|
|
|
247
270
|
// Multiple conditions
|
|
248
271
|
filter('read', ctx => ({
|
|
249
272
|
organization_id: ctx.auth.organizationIds?.[0],
|
|
250
|
-
deleted_at: null
|
|
273
|
+
deleted_at: null
|
|
251
274
|
}))
|
|
252
275
|
```
|
|
253
276
|
|
|
@@ -257,31 +280,28 @@ Validates data during CREATE/UPDATE operations.
|
|
|
257
280
|
|
|
258
281
|
```typescript
|
|
259
282
|
// Validate tenant ownership
|
|
260
|
-
validate('create', ctx =>
|
|
261
|
-
ctx.data.tenant_id === ctx.auth.tenantId
|
|
262
|
-
)
|
|
283
|
+
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
263
284
|
|
|
264
285
|
// Validate status transitions
|
|
265
286
|
validate('update', ctx => {
|
|
266
287
|
const validTransitions = {
|
|
267
288
|
draft: ['published', 'archived'],
|
|
268
289
|
published: ['archived'],
|
|
269
|
-
archived: []
|
|
270
|
-
}
|
|
271
|
-
return !ctx.data.status ||
|
|
272
|
-
validTransitions[ctx.row.status]?.includes(ctx.data.status);
|
|
290
|
+
archived: []
|
|
291
|
+
}
|
|
292
|
+
return !ctx.data.status || validTransitions[ctx.row.status]?.includes(ctx.data.status)
|
|
273
293
|
})
|
|
274
294
|
```
|
|
275
295
|
|
|
276
296
|
### Operations
|
|
277
297
|
|
|
278
|
-
| Operation | SQL
|
|
279
|
-
|
|
280
|
-
| `read`
|
|
281
|
-
| `create`
|
|
282
|
-
| `update`
|
|
283
|
-
| `delete`
|
|
284
|
-
| `all`
|
|
298
|
+
| Operation | SQL | Description |
|
|
299
|
+
| --------- | ------ | ----------------------------- |
|
|
300
|
+
| `read` | SELECT | Control what users can view |
|
|
301
|
+
| `create` | INSERT | Control what users can create |
|
|
302
|
+
| `update` | UPDATE | Control what users can modify |
|
|
303
|
+
| `delete` | DELETE | Control what users can remove |
|
|
304
|
+
| `all` | All | Apply to all operations |
|
|
285
305
|
|
|
286
306
|
```typescript
|
|
287
307
|
// Single operation
|
|
@@ -316,6 +336,7 @@ deny('all', ctx => ctx.auth.suspended)
|
|
|
316
336
|
```
|
|
317
337
|
|
|
318
338
|
**Priority System:**
|
|
339
|
+
|
|
319
340
|
- Higher priority = evaluated first
|
|
320
341
|
- Deny policies default to priority `100`
|
|
321
342
|
- Allow/filter/validate default to priority `0`
|
|
@@ -334,10 +355,10 @@ defineRLSSchema<Database>({
|
|
|
334
355
|
allow('read', ctx => ctx.auth.premium, { priority: 50 }),
|
|
335
356
|
|
|
336
357
|
// Default priority
|
|
337
|
-
allow('read', ctx => ctx.row.public)
|
|
338
|
-
]
|
|
339
|
-
}
|
|
340
|
-
})
|
|
358
|
+
allow('read', ctx => ctx.row.public)
|
|
359
|
+
]
|
|
360
|
+
}
|
|
361
|
+
})
|
|
341
362
|
```
|
|
342
363
|
|
|
343
364
|
---
|
|
@@ -365,8 +386,8 @@ allow('read', ctx => ctx.auth.verified, {
|
|
|
365
386
|
|
|
366
387
|
// Async condition
|
|
367
388
|
allow('update', async ctx => {
|
|
368
|
-
const hasPermission = await checkPermission(ctx.auth.userId, 'posts:edit')
|
|
369
|
-
return hasPermission
|
|
389
|
+
const hasPermission = await checkPermission(ctx.auth.userId, 'posts:edit')
|
|
390
|
+
return hasPermission
|
|
370
391
|
})
|
|
371
392
|
```
|
|
372
393
|
|
|
@@ -405,9 +426,9 @@ filter('read', ctx => ({
|
|
|
405
426
|
// Dynamic filter
|
|
406
427
|
filter('read', ctx => {
|
|
407
428
|
if (ctx.auth.roles.includes('admin')) {
|
|
408
|
-
return {}
|
|
429
|
+
return {} // No filtering
|
|
409
430
|
}
|
|
410
|
-
return { status: 'published', public: true }
|
|
431
|
+
return { status: 'published', public: true }
|
|
411
432
|
})
|
|
412
433
|
|
|
413
434
|
// With hints
|
|
@@ -426,8 +447,8 @@ validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
|
426
447
|
|
|
427
448
|
// Validate update
|
|
428
449
|
validate('update', ctx => {
|
|
429
|
-
const allowedFields = ['title', 'content', 'tags']
|
|
430
|
-
return Object.keys(ctx.data).every(key => allowedFields.includes(key))
|
|
450
|
+
const allowedFields = ['title', 'content', 'tags']
|
|
451
|
+
return Object.keys(ctx.data).every(key => allowedFields.includes(key))
|
|
431
452
|
})
|
|
432
453
|
|
|
433
454
|
// Both create and update
|
|
@@ -439,18 +460,18 @@ validate('all', ctx => !ctx.data.price || ctx.data.price >= 0)
|
|
|
439
460
|
```typescript
|
|
440
461
|
interface PolicyOptions {
|
|
441
462
|
/** Policy name for debugging */
|
|
442
|
-
name?: string
|
|
463
|
+
name?: string
|
|
443
464
|
|
|
444
465
|
/** Priority (higher runs first) */
|
|
445
|
-
priority?: number
|
|
466
|
+
priority?: number
|
|
446
467
|
|
|
447
468
|
/** Performance hints */
|
|
448
469
|
hints?: {
|
|
449
|
-
indexColumns?: string[]
|
|
450
|
-
selectivity?: 'high' | 'medium' | 'low'
|
|
451
|
-
leakproof?: boolean
|
|
452
|
-
stable?: boolean
|
|
453
|
-
}
|
|
470
|
+
indexColumns?: string[]
|
|
471
|
+
selectivity?: 'high' | 'medium' | 'low'
|
|
472
|
+
leakproof?: boolean
|
|
473
|
+
stable?: boolean
|
|
474
|
+
}
|
|
454
475
|
}
|
|
455
476
|
```
|
|
456
477
|
|
|
@@ -465,28 +486,29 @@ The RLS context is stored and managed using AsyncLocalStorage, providing automat
|
|
|
465
486
|
```typescript
|
|
466
487
|
interface RLSContext<TUser = unknown, TMeta = unknown> {
|
|
467
488
|
auth: {
|
|
468
|
-
userId: string | number
|
|
469
|
-
roles: string[]
|
|
470
|
-
tenantId?: string | number
|
|
471
|
-
organizationIds?: (string | number)[]
|
|
472
|
-
permissions?: string[]
|
|
473
|
-
attributes?: Record<string, unknown
|
|
474
|
-
user?: TUser
|
|
475
|
-
isSystem?: boolean
|
|
476
|
-
}
|
|
489
|
+
userId: string | number // Required
|
|
490
|
+
roles: string[] // Required
|
|
491
|
+
tenantId?: string | number // Optional
|
|
492
|
+
organizationIds?: (string | number)[]
|
|
493
|
+
permissions?: string[]
|
|
494
|
+
attributes?: Record<string, unknown>
|
|
495
|
+
user?: TUser
|
|
496
|
+
isSystem?: boolean // Default: false
|
|
497
|
+
}
|
|
477
498
|
request?: {
|
|
478
|
-
requestId?: string
|
|
479
|
-
ipAddress?: string
|
|
480
|
-
userAgent?: string
|
|
481
|
-
timestamp: Date
|
|
482
|
-
headers?: Record<string, string
|
|
483
|
-
}
|
|
484
|
-
meta?: TMeta
|
|
485
|
-
timestamp: Date
|
|
499
|
+
requestId?: string
|
|
500
|
+
ipAddress?: string
|
|
501
|
+
userAgent?: string
|
|
502
|
+
timestamp: Date
|
|
503
|
+
headers?: Record<string, string>
|
|
504
|
+
}
|
|
505
|
+
meta?: TMeta
|
|
506
|
+
timestamp: Date
|
|
486
507
|
}
|
|
487
508
|
```
|
|
488
509
|
|
|
489
510
|
**Context Storage:** The plugin uses `AsyncLocalStorage` internally to store the RLS context, which:
|
|
511
|
+
|
|
490
512
|
- Automatically propagates through async/await chains
|
|
491
513
|
- Is isolated per request (no cross-contamination)
|
|
492
514
|
- Requires no manual passing of context objects
|
|
@@ -507,18 +529,18 @@ await rlsContext.runAsync(
|
|
|
507
529
|
userId: 123,
|
|
508
530
|
roles: ['user'],
|
|
509
531
|
tenantId: 'acme-corp',
|
|
510
|
-
isSystem: false
|
|
532
|
+
isSystem: false
|
|
511
533
|
},
|
|
512
|
-
timestamp: new Date()
|
|
534
|
+
timestamp: new Date()
|
|
513
535
|
},
|
|
514
536
|
async () => {
|
|
515
537
|
// All queries within this block use this context
|
|
516
|
-
const posts = await orm.posts.findAll()
|
|
538
|
+
const posts = await orm.posts.findAll()
|
|
517
539
|
|
|
518
540
|
// Context propagates through async operations
|
|
519
|
-
await orm.posts.create({ title: 'New Post' })
|
|
541
|
+
await orm.posts.create({ title: 'New Post' })
|
|
520
542
|
}
|
|
521
|
-
)
|
|
543
|
+
)
|
|
522
544
|
```
|
|
523
545
|
|
|
524
546
|
#### `rlsContext.run(context, fn)`
|
|
@@ -528,8 +550,8 @@ Run synchronous function within RLS context:
|
|
|
528
550
|
```typescript
|
|
529
551
|
const result = rlsContext.run(context, () => {
|
|
530
552
|
// Synchronous operations
|
|
531
|
-
return someValue
|
|
532
|
-
})
|
|
553
|
+
return someValue
|
|
554
|
+
})
|
|
533
555
|
```
|
|
534
556
|
|
|
535
557
|
#### `createRLSContext(options)`
|
|
@@ -537,30 +559,30 @@ const result = rlsContext.run(context, () => {
|
|
|
537
559
|
Create and validate RLS context with proper defaults:
|
|
538
560
|
|
|
539
561
|
```typescript
|
|
540
|
-
import { createRLSContext } from '@kysera/rls'
|
|
562
|
+
import { createRLSContext } from '@kysera/rls'
|
|
541
563
|
|
|
542
564
|
const ctx = createRLSContext({
|
|
543
565
|
auth: {
|
|
544
566
|
userId: 123,
|
|
545
567
|
roles: ['user', 'editor'],
|
|
546
568
|
tenantId: 'acme-corp',
|
|
547
|
-
permissions: ['posts:read', 'posts:write']
|
|
569
|
+
permissions: ['posts:read', 'posts:write']
|
|
548
570
|
},
|
|
549
571
|
// Optional request context
|
|
550
572
|
request: {
|
|
551
573
|
requestId: 'req-abc123',
|
|
552
574
|
ipAddress: '192.168.1.1',
|
|
553
|
-
timestamp: new Date()
|
|
575
|
+
timestamp: new Date()
|
|
554
576
|
},
|
|
555
577
|
// Optional metadata
|
|
556
578
|
meta: {
|
|
557
|
-
featureFlags: ['beta_access']
|
|
558
|
-
}
|
|
559
|
-
})
|
|
579
|
+
featureFlags: ['beta_access']
|
|
580
|
+
}
|
|
581
|
+
})
|
|
560
582
|
|
|
561
583
|
await rlsContext.runAsync(ctx, async () => {
|
|
562
584
|
// ...
|
|
563
|
-
})
|
|
585
|
+
})
|
|
564
586
|
```
|
|
565
587
|
|
|
566
588
|
#### Context Helper Methods
|
|
@@ -569,10 +591,10 @@ The `rlsContext` singleton provides helper methods for accessing context:
|
|
|
569
591
|
|
|
570
592
|
```typescript
|
|
571
593
|
// Get current context (throws RLSContextError if not set)
|
|
572
|
-
const ctx = rlsContext.getContext()
|
|
594
|
+
const ctx = rlsContext.getContext()
|
|
573
595
|
|
|
574
596
|
// Get context or null (safe, no throw)
|
|
575
|
-
const ctx = rlsContext.getContextOrNull()
|
|
597
|
+
const ctx = rlsContext.getContextOrNull()
|
|
576
598
|
|
|
577
599
|
// Check if running within context
|
|
578
600
|
if (rlsContext.hasContext()) {
|
|
@@ -580,13 +602,13 @@ if (rlsContext.hasContext()) {
|
|
|
580
602
|
}
|
|
581
603
|
|
|
582
604
|
// Get auth context (throws if no context)
|
|
583
|
-
const auth = rlsContext.getAuth()
|
|
605
|
+
const auth = rlsContext.getAuth()
|
|
584
606
|
|
|
585
607
|
// Get user ID (throws if no context)
|
|
586
|
-
const userId = rlsContext.getUserId()
|
|
608
|
+
const userId = rlsContext.getUserId()
|
|
587
609
|
|
|
588
610
|
// Get tenant ID (throws if no context)
|
|
589
|
-
const tenantId = rlsContext.getTenantId()
|
|
611
|
+
const tenantId = rlsContext.getTenantId()
|
|
590
612
|
|
|
591
613
|
// Check if user has role
|
|
592
614
|
if (rlsContext.hasRole('admin')) {
|
|
@@ -606,13 +628,13 @@ if (rlsContext.isSystem()) {
|
|
|
606
628
|
// Run as system user (bypass RLS)
|
|
607
629
|
await rlsContext.asSystemAsync(async () => {
|
|
608
630
|
// All operations bypass RLS policies
|
|
609
|
-
const allPosts = await orm.posts.findAll()
|
|
610
|
-
})
|
|
631
|
+
const allPosts = await orm.posts.findAll()
|
|
632
|
+
})
|
|
611
633
|
|
|
612
634
|
// Synchronous system context
|
|
613
635
|
const result = rlsContext.asSystem(() => {
|
|
614
|
-
return someOperation()
|
|
615
|
-
})
|
|
636
|
+
return someOperation()
|
|
637
|
+
})
|
|
616
638
|
```
|
|
617
639
|
|
|
618
640
|
**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.
|
|
@@ -630,19 +652,19 @@ Bypass RLS policies for specific operations by running them in a system context:
|
|
|
630
652
|
```typescript
|
|
631
653
|
// Fetch all posts including other tenants (bypasses RLS)
|
|
632
654
|
const allPosts = await repo.withoutRLS(async () => {
|
|
633
|
-
return repo.findAll()
|
|
634
|
-
})
|
|
655
|
+
return repo.findAll()
|
|
656
|
+
})
|
|
635
657
|
|
|
636
658
|
// Compare filtered vs unfiltered results
|
|
637
659
|
await rlsContext.runAsync(userContext, async () => {
|
|
638
|
-
const userPosts = await repo.findAll()
|
|
660
|
+
const userPosts = await repo.findAll() // Filtered by RLS policies
|
|
639
661
|
|
|
640
662
|
const allPosts = await repo.withoutRLS(async () => {
|
|
641
|
-
return repo.findAll()
|
|
642
|
-
})
|
|
663
|
+
return repo.findAll() // Bypasses RLS, returns all records
|
|
664
|
+
})
|
|
643
665
|
|
|
644
|
-
console.log(`User can see ${userPosts.length} of ${allPosts.length} total posts`)
|
|
645
|
-
})
|
|
666
|
+
console.log(`User can see ${userPosts.length} of ${allPosts.length} total posts`)
|
|
667
|
+
})
|
|
646
668
|
```
|
|
647
669
|
|
|
648
670
|
**Implementation:** `withoutRLS` internally calls `rlsContext.asSystemAsync(fn)`, which sets `auth.isSystem = true` for the duration of the callback.
|
|
@@ -652,35 +674,36 @@ await rlsContext.runAsync(userContext, async () => {
|
|
|
652
674
|
Check if the current user can perform an operation on a specific row:
|
|
653
675
|
|
|
654
676
|
```typescript
|
|
655
|
-
const post = await repo.findById(postId)
|
|
677
|
+
const post = await repo.findById(postId)
|
|
656
678
|
|
|
657
679
|
// Check read access
|
|
658
|
-
const canRead = await repo.canAccess('read', post)
|
|
680
|
+
const canRead = await repo.canAccess('read', post)
|
|
659
681
|
|
|
660
682
|
// Check update access before showing edit UI
|
|
661
|
-
const canUpdate = await repo.canAccess('update', post)
|
|
683
|
+
const canUpdate = await repo.canAccess('update', post)
|
|
662
684
|
if (canUpdate) {
|
|
663
685
|
// Show edit button in UI
|
|
664
686
|
}
|
|
665
687
|
|
|
666
688
|
// Pre-flight check to avoid policy violations
|
|
667
689
|
if (await repo.canAccess('delete', post)) {
|
|
668
|
-
await repo.delete(post.id)
|
|
690
|
+
await repo.delete(post.id)
|
|
669
691
|
} else {
|
|
670
|
-
console.log('User cannot delete this post')
|
|
692
|
+
console.log('User cannot delete this post')
|
|
671
693
|
}
|
|
672
694
|
|
|
673
695
|
// Check multiple operations
|
|
674
|
-
const operations = ['read', 'update', 'delete'] as const
|
|
696
|
+
const operations = ['read', 'update', 'delete'] as const
|
|
675
697
|
for (const op of operations) {
|
|
676
|
-
const allowed = await repo.canAccess(op, post)
|
|
677
|
-
console.log(`${op}: ${allowed}`)
|
|
698
|
+
const allowed = await repo.canAccess(op, post)
|
|
699
|
+
console.log(`${op}: ${allowed}`)
|
|
678
700
|
}
|
|
679
701
|
```
|
|
680
702
|
|
|
681
703
|
**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.
|
|
682
704
|
|
|
683
705
|
**Supported Operations:**
|
|
706
|
+
|
|
684
707
|
- `'read'` - Check if user can view the row
|
|
685
708
|
- `'create'` - Check if user can create with this data
|
|
686
709
|
- `'update'` - Check if user can update the row
|
|
@@ -690,51 +713,45 @@ for (const op of operations) {
|
|
|
690
713
|
|
|
691
714
|
## DAL Pattern Support
|
|
692
715
|
|
|
693
|
-
RLS works seamlessly with the DAL pattern:
|
|
716
|
+
RLS works seamlessly with the DAL pattern through @kysera/executor's Unified Execution Layer:
|
|
694
717
|
|
|
695
718
|
```typescript
|
|
696
|
-
import { createExecutor } from '@kysera/executor'
|
|
697
|
-
import { createContext, createQuery, withTransaction } from '@kysera/dal'
|
|
698
|
-
import { rlsPlugin, defineRLSSchema, filter, rlsContext } from '@kysera/rls'
|
|
719
|
+
import { createExecutor } from '@kysera/executor'
|
|
720
|
+
import { createContext, createQuery, withTransaction } from '@kysera/dal'
|
|
721
|
+
import { rlsPlugin, defineRLSSchema, filter, rlsContext } from '@kysera/rls'
|
|
699
722
|
|
|
700
723
|
// Define schema
|
|
701
724
|
const rlsSchema = defineRLSSchema<Database>({
|
|
702
725
|
posts: {
|
|
703
|
-
policies: [
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
},
|
|
707
|
-
});
|
|
726
|
+
policies: [filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))]
|
|
727
|
+
}
|
|
728
|
+
})
|
|
708
729
|
|
|
709
|
-
//
|
|
710
|
-
const executor = await createExecutor(db, [
|
|
711
|
-
rlsPlugin({ schema: rlsSchema }),
|
|
712
|
-
]);
|
|
730
|
+
// Step 1: Register RLS plugin with Unified Execution Layer
|
|
731
|
+
const executor = await createExecutor(db, [rlsPlugin({ schema: rlsSchema })])
|
|
713
732
|
|
|
714
|
-
// Create DAL context
|
|
715
|
-
const dalCtx = createContext(executor)
|
|
733
|
+
// Step 2: Create DAL context - plugins automatically apply
|
|
734
|
+
const dalCtx = createContext(executor)
|
|
716
735
|
|
|
717
|
-
// Define queries - RLS applied automatically
|
|
718
|
-
const getPosts = createQuery(
|
|
719
|
-
ctx.db.selectFrom('posts').selectAll().execute()
|
|
720
|
-
);
|
|
736
|
+
// Step 3: Define queries - RLS applied automatically
|
|
737
|
+
const getPosts = createQuery(ctx => ctx.db.selectFrom('posts').selectAll().execute())
|
|
721
738
|
|
|
722
739
|
// Execute within RLS context
|
|
723
740
|
await rlsContext.runAsync(
|
|
724
741
|
{
|
|
725
742
|
auth: { userId: 1, tenantId: 'acme', roles: ['user'], isSystem: false },
|
|
726
|
-
timestamp: new Date()
|
|
743
|
+
timestamp: new Date()
|
|
727
744
|
},
|
|
728
745
|
async () => {
|
|
729
746
|
// Automatically filtered by tenant
|
|
730
|
-
const posts = await getPosts(dalCtx)
|
|
747
|
+
const posts = await getPosts(dalCtx)
|
|
731
748
|
|
|
732
749
|
// Transactions propagate RLS context
|
|
733
|
-
await withTransaction(dalCtx, async
|
|
734
|
-
const txPosts = await getPosts(txCtx)
|
|
735
|
-
})
|
|
750
|
+
await withTransaction(dalCtx, async txCtx => {
|
|
751
|
+
const txPosts = await getPosts(txCtx)
|
|
752
|
+
})
|
|
736
753
|
}
|
|
737
|
-
)
|
|
754
|
+
)
|
|
738
755
|
```
|
|
739
756
|
|
|
740
757
|
---
|
|
@@ -746,33 +763,62 @@ await rlsContext.runAsync(
|
|
|
746
763
|
```typescript
|
|
747
764
|
interface RLSPluginOptions<DB = unknown> {
|
|
748
765
|
/** RLS policy schema (required) */
|
|
749
|
-
schema: RLSSchema<DB
|
|
766
|
+
schema: RLSSchema<DB>
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Tables to exclude from RLS (always bypass)
|
|
770
|
+
* Replaces deprecated skipTables
|
|
771
|
+
*/
|
|
772
|
+
excludeTables?: string[]
|
|
750
773
|
|
|
751
|
-
/**
|
|
752
|
-
skipTables?: string[]
|
|
774
|
+
/** @deprecated Use excludeTables instead */
|
|
775
|
+
skipTables?: string[]
|
|
753
776
|
|
|
754
777
|
/** Roles that bypass RLS entirely */
|
|
755
|
-
bypassRoles?: string[]
|
|
778
|
+
bypassRoles?: string[]
|
|
756
779
|
|
|
757
780
|
/** Logger for RLS operations */
|
|
758
|
-
logger?: KyseraLogger
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
|
|
781
|
+
logger?: KyseraLogger
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Require RLS context (throws if missing)
|
|
785
|
+
* @default true - SECURE BY DEFAULT
|
|
786
|
+
*
|
|
787
|
+
* SECURITY: Changed to true in v0.7.3+ for secure-by-default.
|
|
788
|
+
* When true, missing context throws RLSContextError.
|
|
789
|
+
* Only set to false if you have other security controls.
|
|
790
|
+
*/
|
|
791
|
+
requireContext?: boolean
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Allow unfiltered queries when context is missing
|
|
795
|
+
* @default false - SECURE BY DEFAULT
|
|
796
|
+
*
|
|
797
|
+
* WARNING: Setting true allows queries without RLS filtering
|
|
798
|
+
* when context is missing. Only enable if you understand the
|
|
799
|
+
* security implications (e.g., background jobs, system ops).
|
|
800
|
+
*
|
|
801
|
+
* When requireContext=false and allowUnfilteredQueries=false:
|
|
802
|
+
* - Missing context returns empty results with warnings
|
|
803
|
+
*/
|
|
804
|
+
allowUnfilteredQueries?: boolean
|
|
762
805
|
|
|
763
806
|
/** Enable audit logging of decisions */
|
|
764
|
-
auditDecisions?: boolean
|
|
807
|
+
auditDecisions?: boolean
|
|
765
808
|
|
|
766
809
|
/** Custom violation handler */
|
|
767
|
-
onViolation?: (violation: RLSPolicyViolation) => void
|
|
810
|
+
onViolation?: (violation: RLSPolicyViolation) => void
|
|
811
|
+
|
|
812
|
+
/** Primary key column name (default: 'id') */
|
|
813
|
+
primaryKeyColumn?: string
|
|
768
814
|
}
|
|
769
815
|
```
|
|
770
816
|
|
|
771
817
|
**Example:**
|
|
772
818
|
|
|
773
819
|
```typescript
|
|
774
|
-
import { rlsPlugin } from '@kysera/rls'
|
|
775
|
-
import { createLogger } from '@kysera/core'
|
|
820
|
+
import { rlsPlugin } from '@kysera/rls'
|
|
821
|
+
import { createLogger } from '@kysera/core'
|
|
776
822
|
|
|
777
823
|
const plugin = rlsPlugin({
|
|
778
824
|
schema: rlsSchema,
|
|
@@ -781,19 +827,80 @@ const plugin = rlsPlugin({
|
|
|
781
827
|
logger: createLogger({ level: 'info' }),
|
|
782
828
|
requireContext: true,
|
|
783
829
|
auditDecisions: true,
|
|
784
|
-
onViolation:
|
|
830
|
+
onViolation: violation => {
|
|
785
831
|
auditLog.record({
|
|
786
832
|
type: 'rls_violation',
|
|
787
833
|
operation: violation.operation,
|
|
788
834
|
table: violation.table,
|
|
789
|
-
timestamp: new Date()
|
|
790
|
-
})
|
|
791
|
-
}
|
|
792
|
-
})
|
|
835
|
+
timestamp: new Date()
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
})
|
|
793
839
|
|
|
794
|
-
const orm = await createORM(db, [plugin])
|
|
840
|
+
const orm = await createORM(db, [plugin])
|
|
795
841
|
```
|
|
796
842
|
|
|
843
|
+
### Security Configuration (v0.7.3+)
|
|
844
|
+
|
|
845
|
+
**BREAKING CHANGE**: Starting in v0.7.3, `requireContext` defaults to `true` for secure-by-default behavior.
|
|
846
|
+
|
|
847
|
+
#### Secure Defaults (Recommended)
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
// Default behavior - secure by default
|
|
851
|
+
const plugin = rlsPlugin({
|
|
852
|
+
schema: rlsSchema
|
|
853
|
+
// requireContext: true (implicit)
|
|
854
|
+
// allowUnfilteredQueries: false (implicit)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
// Missing context throws RLSContextError
|
|
858
|
+
await orm.posts.findAll() // ❌ Throws: RLS context required
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
#### Background Jobs / System Operations
|
|
862
|
+
|
|
863
|
+
For operations that legitimately run without user context (e.g., cron jobs, system maintenance):
|
|
864
|
+
|
|
865
|
+
```typescript
|
|
866
|
+
const plugin = rlsPlugin({
|
|
867
|
+
schema: rlsSchema,
|
|
868
|
+
requireContext: false, // Don't throw on missing context
|
|
869
|
+
allowUnfilteredQueries: true // Allow queries without filtering
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
// OR use system context for privileged operations:
|
|
873
|
+
await rlsContext.asSystemAsync(async () => {
|
|
874
|
+
await orm.posts.findAll() // ✅ Runs as system user
|
|
875
|
+
})
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
#### Defensive Mode (No throws, but safe)
|
|
879
|
+
|
|
880
|
+
For applications transitioning to RLS or with mixed code paths:
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
const plugin = rlsPlugin({
|
|
884
|
+
schema: rlsSchema,
|
|
885
|
+
requireContext: false, // Don't throw
|
|
886
|
+
allowUnfilteredQueries: false // Return empty results
|
|
887
|
+
// Missing context logs warning and returns no rows
|
|
888
|
+
})
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
**Security Matrix:**
|
|
892
|
+
|
|
893
|
+
| requireContext | allowUnfilteredQueries | Missing Context Behavior |
|
|
894
|
+
| -------------- | ---------------------- | --------------------------------------- |
|
|
895
|
+
| `true` (default) | N/A | **Throws RLSContextError** (secure) |
|
|
896
|
+
| `false` | `false` (default) | **Returns empty results** (safe) |
|
|
897
|
+
| `false` | `true` | **Allows unfiltered access** (unsafe) |
|
|
898
|
+
|
|
899
|
+
⚠️ **Security Warning**: Only use `allowUnfilteredQueries: true` if you:
|
|
900
|
+
1. Understand the security implications
|
|
901
|
+
2. Have other security controls in place
|
|
902
|
+
3. Are running background jobs or system operations without user context
|
|
903
|
+
|
|
797
904
|
---
|
|
798
905
|
|
|
799
906
|
## Error Handling
|
|
@@ -804,13 +911,13 @@ The RLS plugin provides specialized error classes for different failure scenario
|
|
|
804
911
|
|
|
805
912
|
```typescript
|
|
806
913
|
import {
|
|
807
|
-
RLSError,
|
|
808
|
-
RLSContextError,
|
|
809
|
-
RLSPolicyViolation,
|
|
810
|
-
RLSPolicyEvaluationError,
|
|
811
|
-
RLSSchemaError,
|
|
812
|
-
RLSContextValidationError
|
|
813
|
-
} from '@kysera/rls'
|
|
914
|
+
RLSError, // Base error class
|
|
915
|
+
RLSContextError, // Missing context
|
|
916
|
+
RLSPolicyViolation, // Access denied (expected)
|
|
917
|
+
RLSPolicyEvaluationError, // Bug in policy code (unexpected)
|
|
918
|
+
RLSSchemaError, // Invalid schema
|
|
919
|
+
RLSContextValidationError // Invalid context
|
|
920
|
+
} from '@kysera/rls'
|
|
814
921
|
```
|
|
815
922
|
|
|
816
923
|
### Error Scenarios
|
|
@@ -822,16 +929,17 @@ Thrown when RLS context is missing but required:
|
|
|
822
929
|
```typescript
|
|
823
930
|
try {
|
|
824
931
|
// No context set, but requireContext: true
|
|
825
|
-
await orm.posts.findAll()
|
|
932
|
+
await orm.posts.findAll()
|
|
826
933
|
} catch (error) {
|
|
827
934
|
if (error instanceof RLSContextError) {
|
|
828
935
|
// error.code === 'RLS_CONTEXT_MISSING'
|
|
829
|
-
console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()')
|
|
936
|
+
console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()')
|
|
830
937
|
}
|
|
831
938
|
}
|
|
832
939
|
```
|
|
833
940
|
|
|
834
941
|
**When thrown:**
|
|
942
|
+
|
|
835
943
|
- Operations executed outside `rlsContext.runAsync()` when `requireContext: true`
|
|
836
944
|
- Calling `rlsContext.getContext()` without active context
|
|
837
945
|
- Attempting `asSystem()` without existing context
|
|
@@ -843,27 +951,28 @@ Thrown when operation is denied by policies (this is expected, not a bug):
|
|
|
843
951
|
```typescript
|
|
844
952
|
try {
|
|
845
953
|
// User tries to update a post they don't own
|
|
846
|
-
await orm.posts.update(1, { title: 'New Title' })
|
|
954
|
+
await orm.posts.update(1, { title: 'New Title' })
|
|
847
955
|
} catch (error) {
|
|
848
956
|
if (error instanceof RLSPolicyViolation) {
|
|
849
957
|
// error.code === 'RLS_POLICY_VIOLATION'
|
|
850
958
|
console.error({
|
|
851
|
-
operation: error.operation,
|
|
852
|
-
table: error.table,
|
|
853
|
-
reason: error.reason,
|
|
854
|
-
policyName: error.policyName
|
|
855
|
-
})
|
|
959
|
+
operation: error.operation, // 'update'
|
|
960
|
+
table: error.table, // 'posts'
|
|
961
|
+
reason: error.reason, // 'User does not own this post'
|
|
962
|
+
policyName: error.policyName // 'ownership_policy' (if named)
|
|
963
|
+
})
|
|
856
964
|
|
|
857
965
|
// Return 403 Forbidden to client
|
|
858
966
|
res.status(403).json({
|
|
859
967
|
error: 'Access denied',
|
|
860
|
-
message: error.reason
|
|
861
|
-
})
|
|
968
|
+
message: error.reason
|
|
969
|
+
})
|
|
862
970
|
}
|
|
863
971
|
}
|
|
864
972
|
```
|
|
865
973
|
|
|
866
974
|
**When thrown:**
|
|
975
|
+
|
|
867
976
|
- `deny` policy condition evaluates to `true`
|
|
868
977
|
- No `allow` policy matches and `defaultDeny: true`
|
|
869
978
|
- `validate` policy fails during CREATE/UPDATE
|
|
@@ -874,16 +983,16 @@ Thrown when policy condition throws an error (this is a bug in your policy code)
|
|
|
874
983
|
|
|
875
984
|
```typescript
|
|
876
985
|
try {
|
|
877
|
-
await orm.posts.findAll()
|
|
986
|
+
await orm.posts.findAll()
|
|
878
987
|
} catch (error) {
|
|
879
988
|
if (error instanceof RLSPolicyEvaluationError) {
|
|
880
989
|
// error.code === 'RLS_POLICY_EVALUATION_ERROR'
|
|
881
990
|
console.error({
|
|
882
|
-
operation: error.operation,
|
|
883
|
-
table: error.table,
|
|
884
|
-
policyName: error.policyName,
|
|
885
|
-
originalError: error.originalError
|
|
886
|
-
})
|
|
991
|
+
operation: error.operation, // 'read'
|
|
992
|
+
table: error.table, // 'posts'
|
|
993
|
+
policyName: error.policyName, // 'tenant_filter'
|
|
994
|
+
originalError: error.originalError // TypeError: Cannot read property 'tenantId' of undefined
|
|
995
|
+
})
|
|
887
996
|
|
|
888
997
|
// This is a bug - fix your policy code!
|
|
889
998
|
// Example: Policy tried to access ctx.auth.tenantId but it was undefined
|
|
@@ -892,6 +1001,7 @@ try {
|
|
|
892
1001
|
```
|
|
893
1002
|
|
|
894
1003
|
**When thrown:**
|
|
1004
|
+
|
|
895
1005
|
- Policy condition function throws an error
|
|
896
1006
|
- Policy tries to access undefined properties
|
|
897
1007
|
- Async policy rejects with an error
|
|
@@ -907,16 +1017,16 @@ try {
|
|
|
907
1017
|
const ctx = createRLSContext({
|
|
908
1018
|
auth: {
|
|
909
1019
|
// Missing userId!
|
|
910
|
-
roles: ['user']
|
|
911
|
-
}
|
|
912
|
-
})
|
|
1020
|
+
roles: ['user']
|
|
1021
|
+
}
|
|
1022
|
+
})
|
|
913
1023
|
} catch (error) {
|
|
914
1024
|
if (error instanceof RLSContextValidationError) {
|
|
915
1025
|
// error.code === 'RLS_CONTEXT_INVALID'
|
|
916
1026
|
console.error({
|
|
917
|
-
message: error.message,
|
|
918
|
-
field: error.field
|
|
919
|
-
})
|
|
1027
|
+
message: error.message, // 'userId is required in auth context'
|
|
1028
|
+
field: error.field // 'userId'
|
|
1029
|
+
})
|
|
920
1030
|
}
|
|
921
1031
|
}
|
|
922
1032
|
```
|
|
@@ -934,31 +1044,31 @@ try {
|
|
|
934
1044
|
{ type: 'invalid-type', operation: 'read', condition: () => true }
|
|
935
1045
|
]
|
|
936
1046
|
}
|
|
937
|
-
})
|
|
1047
|
+
})
|
|
938
1048
|
} catch (error) {
|
|
939
1049
|
if (error instanceof RLSSchemaError) {
|
|
940
1050
|
// error.code === 'RLS_SCHEMA_INVALID'
|
|
941
|
-
console.error(error.details)
|
|
1051
|
+
console.error(error.details)
|
|
942
1052
|
}
|
|
943
1053
|
}
|
|
944
1054
|
```
|
|
945
1055
|
|
|
946
1056
|
### Error Comparison Table
|
|
947
1057
|
|
|
948
|
-
| Error
|
|
949
|
-
|
|
950
|
-
| `RLSContextError`
|
|
951
|
-
| `RLSPolicyViolation`
|
|
952
|
-
| `RLSPolicyEvaluationError`
|
|
953
|
-
| `RLSContextValidationError` | Invalid context | Error
|
|
954
|
-
| `RLSSchemaError`
|
|
1058
|
+
| Error | Meaning | Severity | Action |
|
|
1059
|
+
| --------------------------- | --------------- | -------- | ------------------------------------------- |
|
|
1060
|
+
| `RLSContextError` | Missing context | Error | Ensure code runs in `rlsContext.runAsync()` |
|
|
1061
|
+
| `RLSPolicyViolation` | Access denied | Expected | Return 403 to client, normal behavior |
|
|
1062
|
+
| `RLSPolicyEvaluationError` | Policy bug | Critical | Fix the policy code immediately |
|
|
1063
|
+
| `RLSContextValidationError` | Invalid context | Error | Fix context creation |
|
|
1064
|
+
| `RLSSchemaError` | Invalid schema | Error | Fix schema definition |
|
|
955
1065
|
|
|
956
1066
|
### Error Codes
|
|
957
1067
|
|
|
958
1068
|
All RLS errors include a `code` property for programmatic handling:
|
|
959
1069
|
|
|
960
1070
|
```typescript
|
|
961
|
-
import { RLSErrorCodes } from '@kysera/rls'
|
|
1071
|
+
import { RLSErrorCodes } from '@kysera/rls'
|
|
962
1072
|
|
|
963
1073
|
// RLSErrorCodes.RLS_CONTEXT_MISSING
|
|
964
1074
|
// RLSErrorCodes.RLS_POLICY_VIOLATION
|
|
@@ -997,21 +1107,25 @@ The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:
|
|
|
997
1107
|
### Key Components
|
|
998
1108
|
|
|
999
1109
|
**PolicyRegistry:**
|
|
1110
|
+
|
|
1000
1111
|
- Stores and indexes compiled policies by table and operation
|
|
1001
1112
|
- Validates schema structure
|
|
1002
1113
|
- Provides fast policy lookup
|
|
1003
1114
|
|
|
1004
1115
|
**SelectTransformer:**
|
|
1116
|
+
|
|
1005
1117
|
- Transforms SELECT queries by adding WHERE conditions
|
|
1006
1118
|
- Combines multiple filter policies with AND logic
|
|
1007
1119
|
- Evaluates filter conditions in context
|
|
1008
1120
|
|
|
1009
1121
|
**MutationGuard:**
|
|
1122
|
+
|
|
1010
1123
|
- Evaluates allow/deny policies for mutations
|
|
1011
1124
|
- Enforces policy evaluation order (deny → allow → validate)
|
|
1012
1125
|
- Throws `RLSPolicyViolation` or `RLSPolicyEvaluationError`
|
|
1013
1126
|
|
|
1014
1127
|
**AsyncLocalStorage:**
|
|
1128
|
+
|
|
1015
1129
|
- Provides context isolation per request
|
|
1016
1130
|
- Automatic propagation through async/await chains
|
|
1017
1131
|
- No manual context passing required
|
|
@@ -1019,19 +1133,23 @@ The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:
|
|
|
1019
1133
|
### Performance Considerations
|
|
1020
1134
|
|
|
1021
1135
|
**Compiled Policies:**
|
|
1136
|
+
|
|
1022
1137
|
- Policies are compiled once at initialization
|
|
1023
1138
|
- No runtime parsing or compilation overhead
|
|
1024
1139
|
|
|
1025
1140
|
**Filter Application:**
|
|
1141
|
+
|
|
1026
1142
|
- Filters applied as SQL WHERE clauses
|
|
1027
1143
|
- Database handles filtering efficiently
|
|
1028
1144
|
- Index hints available via `PolicyOptions.hints`
|
|
1029
1145
|
|
|
1030
1146
|
**Context Access:**
|
|
1147
|
+
|
|
1031
1148
|
- AsyncLocalStorage is very fast (V8-optimized)
|
|
1032
1149
|
- Context lookup has negligible overhead
|
|
1033
1150
|
|
|
1034
1151
|
**Bypass Mechanisms:**
|
|
1152
|
+
|
|
1035
1153
|
- System context bypass is immediate (no policy evaluation)
|
|
1036
1154
|
- `skipTables` bypass is immediate (no policy evaluation)
|
|
1037
1155
|
- Bypass roles checked before policy evaluation
|
|
@@ -1043,21 +1161,21 @@ RLS context automatically propagates through transactions:
|
|
|
1043
1161
|
```typescript
|
|
1044
1162
|
await rlsContext.runAsync(userContext, async () => {
|
|
1045
1163
|
// Context available in transaction
|
|
1046
|
-
await orm.transaction(async
|
|
1164
|
+
await orm.transaction(async trx => {
|
|
1047
1165
|
// 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
|
-
})
|
|
1166
|
+
const user = await trx.users.findById(userId)
|
|
1167
|
+
await trx.posts.create({ title: 'Post', authorId: userId })
|
|
1168
|
+
})
|
|
1169
|
+
})
|
|
1052
1170
|
```
|
|
1053
1171
|
|
|
1054
1172
|
**Note:** DAL transactions with executor preserve RLS context:
|
|
1055
1173
|
|
|
1056
1174
|
```typescript
|
|
1057
|
-
await withTransaction(executor, async
|
|
1175
|
+
await withTransaction(executor, async txCtx => {
|
|
1058
1176
|
// RLS context preserved in transaction
|
|
1059
|
-
const posts = await getPosts(txCtx)
|
|
1060
|
-
})
|
|
1177
|
+
const posts = await getPosts(txCtx)
|
|
1178
|
+
})
|
|
1061
1179
|
```
|
|
1062
1180
|
|
|
1063
1181
|
---
|
|
@@ -1071,23 +1189,23 @@ const schema = defineRLSSchema<Database>({
|
|
|
1071
1189
|
posts: {
|
|
1072
1190
|
policies: [
|
|
1073
1191
|
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
|
|
1074
|
-
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
1192
|
+
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
1075
1193
|
],
|
|
1076
|
-
defaultDeny: true
|
|
1077
|
-
}
|
|
1078
|
-
})
|
|
1194
|
+
defaultDeny: true
|
|
1195
|
+
}
|
|
1196
|
+
})
|
|
1079
1197
|
|
|
1080
1198
|
app.use(async (req, res, next) => {
|
|
1081
|
-
const user = await authenticate(req)
|
|
1199
|
+
const user = await authenticate(req)
|
|
1082
1200
|
|
|
1083
1201
|
await rlsContext.runAsync(
|
|
1084
1202
|
{ auth: { userId: user.id, tenantId: user.tenant_id, roles: user.roles } },
|
|
1085
1203
|
async () => {
|
|
1086
|
-
const posts = await orm.posts.findAll()
|
|
1087
|
-
res.json(posts)
|
|
1204
|
+
const posts = await orm.posts.findAll()
|
|
1205
|
+
res.json(posts)
|
|
1088
1206
|
}
|
|
1089
|
-
)
|
|
1090
|
-
})
|
|
1207
|
+
)
|
|
1208
|
+
})
|
|
1091
1209
|
```
|
|
1092
1210
|
|
|
1093
1211
|
### Owner-Based Access
|
|
@@ -1103,12 +1221,10 @@ const schema = defineRLSSchema<Database>({
|
|
|
1103
1221
|
allow('read', ctx => ctx.auth.userId === ctx.row.author_id),
|
|
1104
1222
|
|
|
1105
1223
|
// Only owner can update/delete
|
|
1106
|
-
allow(['update', 'delete'], ctx =>
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
},
|
|
1111
|
-
});
|
|
1224
|
+
allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row.author_id)
|
|
1225
|
+
]
|
|
1226
|
+
}
|
|
1227
|
+
})
|
|
1112
1228
|
```
|
|
1113
1229
|
|
|
1114
1230
|
### Role-Based Access Control
|
|
@@ -1121,15 +1237,13 @@ const schema = defineRLSSchema<Database>({
|
|
|
1121
1237
|
allow('all', ctx => ctx.auth.roles.includes('admin')),
|
|
1122
1238
|
|
|
1123
1239
|
// Editors can read and update
|
|
1124
|
-
allow(['read', 'update'], ctx =>
|
|
1125
|
-
ctx.auth.roles.includes('editor')
|
|
1126
|
-
),
|
|
1240
|
+
allow(['read', 'update'], ctx => ctx.auth.roles.includes('editor')),
|
|
1127
1241
|
|
|
1128
1242
|
// Regular users read only
|
|
1129
|
-
allow('read', ctx => ctx.auth.roles.includes('user'))
|
|
1130
|
-
]
|
|
1131
|
-
}
|
|
1132
|
-
})
|
|
1243
|
+
allow('read', ctx => ctx.auth.roles.includes('user'))
|
|
1244
|
+
]
|
|
1245
|
+
}
|
|
1246
|
+
})
|
|
1133
1247
|
```
|
|
1134
1248
|
|
|
1135
1249
|
---
|
|
@@ -1141,30 +1255,30 @@ Full type inference for policies:
|
|
|
1141
1255
|
```typescript
|
|
1142
1256
|
interface Database {
|
|
1143
1257
|
posts: {
|
|
1144
|
-
id: number
|
|
1145
|
-
title: string
|
|
1146
|
-
author_id: number
|
|
1147
|
-
tenant_id: string
|
|
1148
|
-
}
|
|
1258
|
+
id: number
|
|
1259
|
+
title: string
|
|
1260
|
+
author_id: number
|
|
1261
|
+
tenant_id: string
|
|
1262
|
+
}
|
|
1149
1263
|
}
|
|
1150
1264
|
|
|
1151
1265
|
const schema = defineRLSSchema<Database>({
|
|
1152
1266
|
posts: {
|
|
1153
1267
|
policies: [
|
|
1154
1268
|
allow('read', ctx => {
|
|
1155
|
-
const post = ctx.row
|
|
1156
|
-
const userId = ctx.auth.userId
|
|
1157
|
-
return post.author_id === userId
|
|
1269
|
+
const post = ctx.row // Type: Database['posts']
|
|
1270
|
+
const userId = ctx.auth.userId // Type: string | number
|
|
1271
|
+
return post.author_id === userId
|
|
1158
1272
|
}),
|
|
1159
1273
|
|
|
1160
1274
|
validate('update', ctx => {
|
|
1161
|
-
const data = ctx.data
|
|
1162
|
-
const title = data.title
|
|
1163
|
-
return !title || title.length > 0
|
|
1164
|
-
})
|
|
1165
|
-
]
|
|
1166
|
-
}
|
|
1167
|
-
})
|
|
1275
|
+
const data = ctx.data // Type: Partial<Database['posts']>
|
|
1276
|
+
const title = data.title // Type: string | undefined
|
|
1277
|
+
return !title || title.length > 0
|
|
1278
|
+
})
|
|
1279
|
+
]
|
|
1280
|
+
}
|
|
1281
|
+
})
|
|
1168
1282
|
```
|
|
1169
1283
|
|
|
1170
1284
|
---
|
|
@@ -1175,13 +1289,13 @@ const schema = defineRLSSchema<Database>({
|
|
|
1175
1289
|
|
|
1176
1290
|
```typescript
|
|
1177
1291
|
// Schema definition
|
|
1178
|
-
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
|
|
1292
|
+
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
|
|
1179
1293
|
|
|
1180
1294
|
// Policy builders
|
|
1181
|
-
export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls'
|
|
1295
|
+
export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls'
|
|
1182
1296
|
|
|
1183
1297
|
// Plugin
|
|
1184
|
-
export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls'
|
|
1298
|
+
export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls'
|
|
1185
1299
|
|
|
1186
1300
|
// Context management
|
|
1187
1301
|
export {
|
|
@@ -1189,8 +1303,8 @@ export {
|
|
|
1189
1303
|
createRLSContext,
|
|
1190
1304
|
withRLSContext,
|
|
1191
1305
|
withRLSContextAsync,
|
|
1192
|
-
type RLSContext
|
|
1193
|
-
} from '@kysera/rls'
|
|
1306
|
+
type RLSContext
|
|
1307
|
+
} from '@kysera/rls'
|
|
1194
1308
|
|
|
1195
1309
|
// Errors
|
|
1196
1310
|
export {
|
|
@@ -1200,8 +1314,8 @@ export {
|
|
|
1200
1314
|
RLSPolicyEvaluationError,
|
|
1201
1315
|
RLSSchemaError,
|
|
1202
1316
|
RLSContextValidationError,
|
|
1203
|
-
RLSErrorCodes
|
|
1204
|
-
} from '@kysera/rls'
|
|
1317
|
+
RLSErrorCodes
|
|
1318
|
+
} from '@kysera/rls'
|
|
1205
1319
|
```
|
|
1206
1320
|
|
|
1207
1321
|
---
|