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