@kysera/rls 0.7.3 → 0.8.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 +389 -279
- package/dist/index.d.ts +89 -20
- package/dist/index.js +210 -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 +16 -7
- 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 +286 -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** - 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.
|
|
153
|
+
version: '0.8.0',
|
|
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
|
-
- Skip conditions: `
|
|
195
|
+
- Skip conditions: `excludeTables`, `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,54 +763,140 @@ 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>
|
|
750
767
|
|
|
751
|
-
/**
|
|
752
|
-
|
|
768
|
+
/**
|
|
769
|
+
* Tables to exclude from RLS (always bypass)
|
|
770
|
+
*/
|
|
771
|
+
excludeTables?: string[]
|
|
753
772
|
|
|
754
773
|
/** Roles that bypass RLS entirely */
|
|
755
|
-
bypassRoles?: string[]
|
|
774
|
+
bypassRoles?: string[]
|
|
756
775
|
|
|
757
776
|
/** Logger for RLS operations */
|
|
758
|
-
logger?: KyseraLogger
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
|
|
777
|
+
logger?: KyseraLogger
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Require RLS context (throws if missing)
|
|
781
|
+
* @default true - SECURE BY DEFAULT
|
|
782
|
+
*
|
|
783
|
+
* SECURITY: Changed to true in v0.7.3+ for secure-by-default.
|
|
784
|
+
* When true, missing context throws RLSContextError.
|
|
785
|
+
* Only set to false if you have other security controls.
|
|
786
|
+
*/
|
|
787
|
+
requireContext?: boolean
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Allow unfiltered queries when context is missing
|
|
791
|
+
* @default false - SECURE BY DEFAULT
|
|
792
|
+
*
|
|
793
|
+
* WARNING: Setting true allows queries without RLS filtering
|
|
794
|
+
* when context is missing. Only enable if you understand the
|
|
795
|
+
* security implications (e.g., background jobs, system ops).
|
|
796
|
+
*
|
|
797
|
+
* When requireContext=false and allowUnfilteredQueries=false:
|
|
798
|
+
* - Missing context returns empty results with warnings
|
|
799
|
+
*/
|
|
800
|
+
allowUnfilteredQueries?: boolean
|
|
762
801
|
|
|
763
802
|
/** Enable audit logging of decisions */
|
|
764
|
-
auditDecisions?: boolean
|
|
803
|
+
auditDecisions?: boolean
|
|
765
804
|
|
|
766
805
|
/** Custom violation handler */
|
|
767
|
-
onViolation?: (violation: RLSPolicyViolation) => void
|
|
806
|
+
onViolation?: (violation: RLSPolicyViolation) => void
|
|
807
|
+
|
|
808
|
+
/** Primary key column name (default: 'id') */
|
|
809
|
+
primaryKeyColumn?: string
|
|
768
810
|
}
|
|
769
811
|
```
|
|
770
812
|
|
|
771
813
|
**Example:**
|
|
772
814
|
|
|
773
815
|
```typescript
|
|
774
|
-
import { rlsPlugin } from '@kysera/rls'
|
|
775
|
-
import { createLogger } from '@kysera/core'
|
|
816
|
+
import { rlsPlugin } from '@kysera/rls'
|
|
817
|
+
import { createLogger } from '@kysera/core'
|
|
776
818
|
|
|
777
819
|
const plugin = rlsPlugin({
|
|
778
820
|
schema: rlsSchema,
|
|
779
|
-
|
|
821
|
+
excludeTables: ['audit_logs', 'migrations'],
|
|
780
822
|
bypassRoles: ['admin', 'system'],
|
|
781
823
|
logger: createLogger({ level: 'info' }),
|
|
782
824
|
requireContext: true,
|
|
783
825
|
auditDecisions: true,
|
|
784
|
-
onViolation:
|
|
826
|
+
onViolation: violation => {
|
|
785
827
|
auditLog.record({
|
|
786
828
|
type: 'rls_violation',
|
|
787
829
|
operation: violation.operation,
|
|
788
830
|
table: violation.table,
|
|
789
|
-
timestamp: new Date()
|
|
790
|
-
})
|
|
791
|
-
}
|
|
792
|
-
})
|
|
831
|
+
timestamp: new Date()
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
const orm = await createORM(db, [plugin])
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
### Security Configuration (v0.7.3+)
|
|
840
|
+
|
|
841
|
+
**BREAKING CHANGE**: Starting in v0.7.3, `requireContext` defaults to `true` for secure-by-default behavior.
|
|
842
|
+
|
|
843
|
+
#### Secure Defaults (Recommended)
|
|
844
|
+
|
|
845
|
+
```typescript
|
|
846
|
+
// Default behavior - secure by default
|
|
847
|
+
const plugin = rlsPlugin({
|
|
848
|
+
schema: rlsSchema
|
|
849
|
+
// requireContext: true (implicit)
|
|
850
|
+
// allowUnfilteredQueries: false (implicit)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
// Missing context throws RLSContextError
|
|
854
|
+
await orm.posts.findAll() // ❌ Throws: RLS context required
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
#### Background Jobs / System Operations
|
|
858
|
+
|
|
859
|
+
For operations that legitimately run without user context (e.g., cron jobs, system maintenance):
|
|
860
|
+
|
|
861
|
+
```typescript
|
|
862
|
+
const plugin = rlsPlugin({
|
|
863
|
+
schema: rlsSchema,
|
|
864
|
+
requireContext: false, // Don't throw on missing context
|
|
865
|
+
allowUnfilteredQueries: true // Allow queries without filtering
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
// OR use system context for privileged operations:
|
|
869
|
+
await rlsContext.asSystemAsync(async () => {
|
|
870
|
+
await orm.posts.findAll() // ✅ Runs as system user
|
|
871
|
+
})
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
#### Defensive Mode (No throws, but safe)
|
|
793
875
|
|
|
794
|
-
|
|
876
|
+
For applications transitioning to RLS or with mixed code paths:
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
const plugin = rlsPlugin({
|
|
880
|
+
schema: rlsSchema,
|
|
881
|
+
requireContext: false, // Don't throw
|
|
882
|
+
allowUnfilteredQueries: false // Return empty results
|
|
883
|
+
// Missing context logs warning and returns no rows
|
|
884
|
+
})
|
|
795
885
|
```
|
|
796
886
|
|
|
887
|
+
**Security Matrix:**
|
|
888
|
+
|
|
889
|
+
| requireContext | allowUnfilteredQueries | Missing Context Behavior |
|
|
890
|
+
| -------------- | ---------------------- | --------------------------------------- |
|
|
891
|
+
| `true` (default) | N/A | **Throws RLSContextError** (secure) |
|
|
892
|
+
| `false` | `false` (default) | **Returns empty results** (safe) |
|
|
893
|
+
| `false` | `true` | **Allows unfiltered access** (unsafe) |
|
|
894
|
+
|
|
895
|
+
⚠️ **Security Warning**: Only use `allowUnfilteredQueries: true` if you:
|
|
896
|
+
1. Understand the security implications
|
|
897
|
+
2. Have other security controls in place
|
|
898
|
+
3. Are running background jobs or system operations without user context
|
|
899
|
+
|
|
797
900
|
---
|
|
798
901
|
|
|
799
902
|
## Error Handling
|
|
@@ -804,13 +907,13 @@ The RLS plugin provides specialized error classes for different failure scenario
|
|
|
804
907
|
|
|
805
908
|
```typescript
|
|
806
909
|
import {
|
|
807
|
-
RLSError,
|
|
808
|
-
RLSContextError,
|
|
809
|
-
RLSPolicyViolation,
|
|
810
|
-
RLSPolicyEvaluationError,
|
|
811
|
-
RLSSchemaError,
|
|
812
|
-
RLSContextValidationError
|
|
813
|
-
} from '@kysera/rls'
|
|
910
|
+
RLSError, // Base error class
|
|
911
|
+
RLSContextError, // Missing context
|
|
912
|
+
RLSPolicyViolation, // Access denied (expected)
|
|
913
|
+
RLSPolicyEvaluationError, // Bug in policy code (unexpected)
|
|
914
|
+
RLSSchemaError, // Invalid schema
|
|
915
|
+
RLSContextValidationError // Invalid context
|
|
916
|
+
} from '@kysera/rls'
|
|
814
917
|
```
|
|
815
918
|
|
|
816
919
|
### Error Scenarios
|
|
@@ -822,16 +925,17 @@ Thrown when RLS context is missing but required:
|
|
|
822
925
|
```typescript
|
|
823
926
|
try {
|
|
824
927
|
// No context set, but requireContext: true
|
|
825
|
-
await orm.posts.findAll()
|
|
928
|
+
await orm.posts.findAll()
|
|
826
929
|
} catch (error) {
|
|
827
930
|
if (error instanceof RLSContextError) {
|
|
828
931
|
// error.code === 'RLS_CONTEXT_MISSING'
|
|
829
|
-
console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()')
|
|
932
|
+
console.error('No RLS context found. Ensure code runs within rlsContext.runAsync()')
|
|
830
933
|
}
|
|
831
934
|
}
|
|
832
935
|
```
|
|
833
936
|
|
|
834
937
|
**When thrown:**
|
|
938
|
+
|
|
835
939
|
- Operations executed outside `rlsContext.runAsync()` when `requireContext: true`
|
|
836
940
|
- Calling `rlsContext.getContext()` without active context
|
|
837
941
|
- Attempting `asSystem()` without existing context
|
|
@@ -843,27 +947,28 @@ Thrown when operation is denied by policies (this is expected, not a bug):
|
|
|
843
947
|
```typescript
|
|
844
948
|
try {
|
|
845
949
|
// User tries to update a post they don't own
|
|
846
|
-
await orm.posts.update(1, { title: 'New Title' })
|
|
950
|
+
await orm.posts.update(1, { title: 'New Title' })
|
|
847
951
|
} catch (error) {
|
|
848
952
|
if (error instanceof RLSPolicyViolation) {
|
|
849
953
|
// error.code === 'RLS_POLICY_VIOLATION'
|
|
850
954
|
console.error({
|
|
851
|
-
operation: error.operation,
|
|
852
|
-
table: error.table,
|
|
853
|
-
reason: error.reason,
|
|
854
|
-
policyName: error.policyName
|
|
855
|
-
})
|
|
955
|
+
operation: error.operation, // 'update'
|
|
956
|
+
table: error.table, // 'posts'
|
|
957
|
+
reason: error.reason, // 'User does not own this post'
|
|
958
|
+
policyName: error.policyName // 'ownership_policy' (if named)
|
|
959
|
+
})
|
|
856
960
|
|
|
857
961
|
// Return 403 Forbidden to client
|
|
858
962
|
res.status(403).json({
|
|
859
963
|
error: 'Access denied',
|
|
860
|
-
message: error.reason
|
|
861
|
-
})
|
|
964
|
+
message: error.reason
|
|
965
|
+
})
|
|
862
966
|
}
|
|
863
967
|
}
|
|
864
968
|
```
|
|
865
969
|
|
|
866
970
|
**When thrown:**
|
|
971
|
+
|
|
867
972
|
- `deny` policy condition evaluates to `true`
|
|
868
973
|
- No `allow` policy matches and `defaultDeny: true`
|
|
869
974
|
- `validate` policy fails during CREATE/UPDATE
|
|
@@ -874,16 +979,16 @@ Thrown when policy condition throws an error (this is a bug in your policy code)
|
|
|
874
979
|
|
|
875
980
|
```typescript
|
|
876
981
|
try {
|
|
877
|
-
await orm.posts.findAll()
|
|
982
|
+
await orm.posts.findAll()
|
|
878
983
|
} catch (error) {
|
|
879
984
|
if (error instanceof RLSPolicyEvaluationError) {
|
|
880
985
|
// error.code === 'RLS_POLICY_EVALUATION_ERROR'
|
|
881
986
|
console.error({
|
|
882
|
-
operation: error.operation,
|
|
883
|
-
table: error.table,
|
|
884
|
-
policyName: error.policyName,
|
|
885
|
-
originalError: error.originalError
|
|
886
|
-
})
|
|
987
|
+
operation: error.operation, // 'read'
|
|
988
|
+
table: error.table, // 'posts'
|
|
989
|
+
policyName: error.policyName, // 'tenant_filter'
|
|
990
|
+
originalError: error.originalError // TypeError: Cannot read property 'tenantId' of undefined
|
|
991
|
+
})
|
|
887
992
|
|
|
888
993
|
// This is a bug - fix your policy code!
|
|
889
994
|
// Example: Policy tried to access ctx.auth.tenantId but it was undefined
|
|
@@ -892,6 +997,7 @@ try {
|
|
|
892
997
|
```
|
|
893
998
|
|
|
894
999
|
**When thrown:**
|
|
1000
|
+
|
|
895
1001
|
- Policy condition function throws an error
|
|
896
1002
|
- Policy tries to access undefined properties
|
|
897
1003
|
- Async policy rejects with an error
|
|
@@ -907,16 +1013,16 @@ try {
|
|
|
907
1013
|
const ctx = createRLSContext({
|
|
908
1014
|
auth: {
|
|
909
1015
|
// Missing userId!
|
|
910
|
-
roles: ['user']
|
|
911
|
-
}
|
|
912
|
-
})
|
|
1016
|
+
roles: ['user']
|
|
1017
|
+
}
|
|
1018
|
+
})
|
|
913
1019
|
} catch (error) {
|
|
914
1020
|
if (error instanceof RLSContextValidationError) {
|
|
915
1021
|
// error.code === 'RLS_CONTEXT_INVALID'
|
|
916
1022
|
console.error({
|
|
917
|
-
message: error.message,
|
|
918
|
-
field: error.field
|
|
919
|
-
})
|
|
1023
|
+
message: error.message, // 'userId is required in auth context'
|
|
1024
|
+
field: error.field // 'userId'
|
|
1025
|
+
})
|
|
920
1026
|
}
|
|
921
1027
|
}
|
|
922
1028
|
```
|
|
@@ -934,31 +1040,31 @@ try {
|
|
|
934
1040
|
{ type: 'invalid-type', operation: 'read', condition: () => true }
|
|
935
1041
|
]
|
|
936
1042
|
}
|
|
937
|
-
})
|
|
1043
|
+
})
|
|
938
1044
|
} catch (error) {
|
|
939
1045
|
if (error instanceof RLSSchemaError) {
|
|
940
1046
|
// error.code === 'RLS_SCHEMA_INVALID'
|
|
941
|
-
console.error(error.details)
|
|
1047
|
+
console.error(error.details)
|
|
942
1048
|
}
|
|
943
1049
|
}
|
|
944
1050
|
```
|
|
945
1051
|
|
|
946
1052
|
### Error Comparison Table
|
|
947
1053
|
|
|
948
|
-
| Error
|
|
949
|
-
|
|
950
|
-
| `RLSContextError`
|
|
951
|
-
| `RLSPolicyViolation`
|
|
952
|
-
| `RLSPolicyEvaluationError`
|
|
953
|
-
| `RLSContextValidationError` | Invalid context | Error
|
|
954
|
-
| `RLSSchemaError`
|
|
1054
|
+
| Error | Meaning | Severity | Action |
|
|
1055
|
+
| --------------------------- | --------------- | -------- | ------------------------------------------- |
|
|
1056
|
+
| `RLSContextError` | Missing context | Error | Ensure code runs in `rlsContext.runAsync()` |
|
|
1057
|
+
| `RLSPolicyViolation` | Access denied | Expected | Return 403 to client, normal behavior |
|
|
1058
|
+
| `RLSPolicyEvaluationError` | Policy bug | Critical | Fix the policy code immediately |
|
|
1059
|
+
| `RLSContextValidationError` | Invalid context | Error | Fix context creation |
|
|
1060
|
+
| `RLSSchemaError` | Invalid schema | Error | Fix schema definition |
|
|
955
1061
|
|
|
956
1062
|
### Error Codes
|
|
957
1063
|
|
|
958
1064
|
All RLS errors include a `code` property for programmatic handling:
|
|
959
1065
|
|
|
960
1066
|
```typescript
|
|
961
|
-
import { RLSErrorCodes } from '@kysera/rls'
|
|
1067
|
+
import { RLSErrorCodes } from '@kysera/rls'
|
|
962
1068
|
|
|
963
1069
|
// RLSErrorCodes.RLS_CONTEXT_MISSING
|
|
964
1070
|
// RLSErrorCodes.RLS_POLICY_VIOLATION
|
|
@@ -997,21 +1103,25 @@ The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:
|
|
|
997
1103
|
### Key Components
|
|
998
1104
|
|
|
999
1105
|
**PolicyRegistry:**
|
|
1106
|
+
|
|
1000
1107
|
- Stores and indexes compiled policies by table and operation
|
|
1001
1108
|
- Validates schema structure
|
|
1002
1109
|
- Provides fast policy lookup
|
|
1003
1110
|
|
|
1004
1111
|
**SelectTransformer:**
|
|
1112
|
+
|
|
1005
1113
|
- Transforms SELECT queries by adding WHERE conditions
|
|
1006
1114
|
- Combines multiple filter policies with AND logic
|
|
1007
1115
|
- Evaluates filter conditions in context
|
|
1008
1116
|
|
|
1009
1117
|
**MutationGuard:**
|
|
1118
|
+
|
|
1010
1119
|
- Evaluates allow/deny policies for mutations
|
|
1011
1120
|
- Enforces policy evaluation order (deny → allow → validate)
|
|
1012
1121
|
- Throws `RLSPolicyViolation` or `RLSPolicyEvaluationError`
|
|
1013
1122
|
|
|
1014
1123
|
**AsyncLocalStorage:**
|
|
1124
|
+
|
|
1015
1125
|
- Provides context isolation per request
|
|
1016
1126
|
- Automatic propagation through async/await chains
|
|
1017
1127
|
- No manual context passing required
|
|
@@ -1019,21 +1129,25 @@ The RLS plugin follows the standard `@kysera/executor` plugin lifecycle:
|
|
|
1019
1129
|
### Performance Considerations
|
|
1020
1130
|
|
|
1021
1131
|
**Compiled Policies:**
|
|
1132
|
+
|
|
1022
1133
|
- Policies are compiled once at initialization
|
|
1023
1134
|
- No runtime parsing or compilation overhead
|
|
1024
1135
|
|
|
1025
1136
|
**Filter Application:**
|
|
1137
|
+
|
|
1026
1138
|
- Filters applied as SQL WHERE clauses
|
|
1027
1139
|
- Database handles filtering efficiently
|
|
1028
1140
|
- Index hints available via `PolicyOptions.hints`
|
|
1029
1141
|
|
|
1030
1142
|
**Context Access:**
|
|
1143
|
+
|
|
1031
1144
|
- AsyncLocalStorage is very fast (V8-optimized)
|
|
1032
1145
|
- Context lookup has negligible overhead
|
|
1033
1146
|
|
|
1034
1147
|
**Bypass Mechanisms:**
|
|
1148
|
+
|
|
1035
1149
|
- System context bypass is immediate (no policy evaluation)
|
|
1036
|
-
- `
|
|
1150
|
+
- `excludeTables` bypass is immediate (no policy evaluation)
|
|
1037
1151
|
- Bypass roles checked before policy evaluation
|
|
1038
1152
|
|
|
1039
1153
|
### Transaction Support
|
|
@@ -1043,21 +1157,21 @@ RLS context automatically propagates through transactions:
|
|
|
1043
1157
|
```typescript
|
|
1044
1158
|
await rlsContext.runAsync(userContext, async () => {
|
|
1045
1159
|
// Context available in transaction
|
|
1046
|
-
await orm.transaction(async
|
|
1160
|
+
await orm.transaction(async trx => {
|
|
1047
1161
|
// 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
|
-
})
|
|
1162
|
+
const user = await trx.users.findById(userId)
|
|
1163
|
+
await trx.posts.create({ title: 'Post', authorId: userId })
|
|
1164
|
+
})
|
|
1165
|
+
})
|
|
1052
1166
|
```
|
|
1053
1167
|
|
|
1054
1168
|
**Note:** DAL transactions with executor preserve RLS context:
|
|
1055
1169
|
|
|
1056
1170
|
```typescript
|
|
1057
|
-
await withTransaction(executor, async
|
|
1171
|
+
await withTransaction(executor, async txCtx => {
|
|
1058
1172
|
// RLS context preserved in transaction
|
|
1059
|
-
const posts = await getPosts(txCtx)
|
|
1060
|
-
})
|
|
1173
|
+
const posts = await getPosts(txCtx)
|
|
1174
|
+
})
|
|
1061
1175
|
```
|
|
1062
1176
|
|
|
1063
1177
|
---
|
|
@@ -1071,23 +1185,23 @@ const schema = defineRLSSchema<Database>({
|
|
|
1071
1185
|
posts: {
|
|
1072
1186
|
policies: [
|
|
1073
1187
|
filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
|
|
1074
|
-
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
1188
|
+
validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId)
|
|
1075
1189
|
],
|
|
1076
|
-
defaultDeny: true
|
|
1077
|
-
}
|
|
1078
|
-
})
|
|
1190
|
+
defaultDeny: true
|
|
1191
|
+
}
|
|
1192
|
+
})
|
|
1079
1193
|
|
|
1080
1194
|
app.use(async (req, res, next) => {
|
|
1081
|
-
const user = await authenticate(req)
|
|
1195
|
+
const user = await authenticate(req)
|
|
1082
1196
|
|
|
1083
1197
|
await rlsContext.runAsync(
|
|
1084
1198
|
{ auth: { userId: user.id, tenantId: user.tenant_id, roles: user.roles } },
|
|
1085
1199
|
async () => {
|
|
1086
|
-
const posts = await orm.posts.findAll()
|
|
1087
|
-
res.json(posts)
|
|
1200
|
+
const posts = await orm.posts.findAll()
|
|
1201
|
+
res.json(posts)
|
|
1088
1202
|
}
|
|
1089
|
-
)
|
|
1090
|
-
})
|
|
1203
|
+
)
|
|
1204
|
+
})
|
|
1091
1205
|
```
|
|
1092
1206
|
|
|
1093
1207
|
### Owner-Based Access
|
|
@@ -1103,12 +1217,10 @@ const schema = defineRLSSchema<Database>({
|
|
|
1103
1217
|
allow('read', ctx => ctx.auth.userId === ctx.row.author_id),
|
|
1104
1218
|
|
|
1105
1219
|
// Only owner can update/delete
|
|
1106
|
-
allow(['update', 'delete'], ctx =>
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
},
|
|
1111
|
-
});
|
|
1220
|
+
allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row.author_id)
|
|
1221
|
+
]
|
|
1222
|
+
}
|
|
1223
|
+
})
|
|
1112
1224
|
```
|
|
1113
1225
|
|
|
1114
1226
|
### Role-Based Access Control
|
|
@@ -1121,15 +1233,13 @@ const schema = defineRLSSchema<Database>({
|
|
|
1121
1233
|
allow('all', ctx => ctx.auth.roles.includes('admin')),
|
|
1122
1234
|
|
|
1123
1235
|
// Editors can read and update
|
|
1124
|
-
allow(['read', 'update'], ctx =>
|
|
1125
|
-
ctx.auth.roles.includes('editor')
|
|
1126
|
-
),
|
|
1236
|
+
allow(['read', 'update'], ctx => ctx.auth.roles.includes('editor')),
|
|
1127
1237
|
|
|
1128
1238
|
// Regular users read only
|
|
1129
|
-
allow('read', ctx => ctx.auth.roles.includes('user'))
|
|
1130
|
-
]
|
|
1131
|
-
}
|
|
1132
|
-
})
|
|
1239
|
+
allow('read', ctx => ctx.auth.roles.includes('user'))
|
|
1240
|
+
]
|
|
1241
|
+
}
|
|
1242
|
+
})
|
|
1133
1243
|
```
|
|
1134
1244
|
|
|
1135
1245
|
---
|
|
@@ -1141,30 +1251,30 @@ Full type inference for policies:
|
|
|
1141
1251
|
```typescript
|
|
1142
1252
|
interface Database {
|
|
1143
1253
|
posts: {
|
|
1144
|
-
id: number
|
|
1145
|
-
title: string
|
|
1146
|
-
author_id: number
|
|
1147
|
-
tenant_id: string
|
|
1148
|
-
}
|
|
1254
|
+
id: number
|
|
1255
|
+
title: string
|
|
1256
|
+
author_id: number
|
|
1257
|
+
tenant_id: string
|
|
1258
|
+
}
|
|
1149
1259
|
}
|
|
1150
1260
|
|
|
1151
1261
|
const schema = defineRLSSchema<Database>({
|
|
1152
1262
|
posts: {
|
|
1153
1263
|
policies: [
|
|
1154
1264
|
allow('read', ctx => {
|
|
1155
|
-
const post = ctx.row
|
|
1156
|
-
const userId = ctx.auth.userId
|
|
1157
|
-
return post.author_id === userId
|
|
1265
|
+
const post = ctx.row // Type: Database['posts']
|
|
1266
|
+
const userId = ctx.auth.userId // Type: string | number
|
|
1267
|
+
return post.author_id === userId
|
|
1158
1268
|
}),
|
|
1159
1269
|
|
|
1160
1270
|
validate('update', ctx => {
|
|
1161
|
-
const data = ctx.data
|
|
1162
|
-
const title = data.title
|
|
1163
|
-
return !title || title.length > 0
|
|
1164
|
-
})
|
|
1165
|
-
]
|
|
1166
|
-
}
|
|
1167
|
-
})
|
|
1271
|
+
const data = ctx.data // Type: Partial<Database['posts']>
|
|
1272
|
+
const title = data.title // Type: string | undefined
|
|
1273
|
+
return !title || title.length > 0
|
|
1274
|
+
})
|
|
1275
|
+
]
|
|
1276
|
+
}
|
|
1277
|
+
})
|
|
1168
1278
|
```
|
|
1169
1279
|
|
|
1170
1280
|
---
|
|
@@ -1175,13 +1285,13 @@ const schema = defineRLSSchema<Database>({
|
|
|
1175
1285
|
|
|
1176
1286
|
```typescript
|
|
1177
1287
|
// Schema definition
|
|
1178
|
-
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
|
|
1288
|
+
export { defineRLSSchema, mergeRLSSchemas } from '@kysera/rls'
|
|
1179
1289
|
|
|
1180
1290
|
// Policy builders
|
|
1181
|
-
export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls'
|
|
1291
|
+
export { allow, deny, filter, validate, type PolicyOptions } from '@kysera/rls'
|
|
1182
1292
|
|
|
1183
1293
|
// Plugin
|
|
1184
|
-
export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls'
|
|
1294
|
+
export { rlsPlugin, type RLSPluginOptions } from '@kysera/rls'
|
|
1185
1295
|
|
|
1186
1296
|
// Context management
|
|
1187
1297
|
export {
|
|
@@ -1189,8 +1299,8 @@ export {
|
|
|
1189
1299
|
createRLSContext,
|
|
1190
1300
|
withRLSContext,
|
|
1191
1301
|
withRLSContextAsync,
|
|
1192
|
-
type RLSContext
|
|
1193
|
-
} from '@kysera/rls'
|
|
1302
|
+
type RLSContext
|
|
1303
|
+
} from '@kysera/rls'
|
|
1194
1304
|
|
|
1195
1305
|
// Errors
|
|
1196
1306
|
export {
|
|
@@ -1200,8 +1310,8 @@ export {
|
|
|
1200
1310
|
RLSPolicyEvaluationError,
|
|
1201
1311
|
RLSSchemaError,
|
|
1202
1312
|
RLSContextValidationError,
|
|
1203
|
-
RLSErrorCodes
|
|
1204
|
-
} from '@kysera/rls'
|
|
1313
|
+
RLSErrorCodes
|
|
1314
|
+
} from '@kysera/rls'
|
|
1205
1315
|
```
|
|
1206
1316
|
|
|
1207
1317
|
---
|