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