@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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Native PostgreSQL RLS Generation
|
|
2
|
+
|
|
3
|
+
This module provides native PostgreSQL Row-Level Security (RLS) policy generation from Kysera RLS schemas.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **PostgreSQL RLS Generation**: Convert Kysera RLS schemas to native PostgreSQL `CREATE POLICY` statements
|
|
8
|
+
- **Context Functions**: Generate STABLE PostgreSQL functions for RLS context (optimized for query planner)
|
|
9
|
+
- **Migration Support**: Generate Kysely migration files with up/down support
|
|
10
|
+
- **Session Management**: Utilities to sync RLS context to PostgreSQL session variables
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### 1. Define RLS Schema with Native PostgreSQL Support
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import type { RLSSchema } from '@kysera/rls';
|
|
18
|
+
|
|
19
|
+
interface Database {
|
|
20
|
+
users: {
|
|
21
|
+
id: number;
|
|
22
|
+
email: string;
|
|
23
|
+
tenant_id: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rlsSchema: RLSSchema<Database> = {
|
|
28
|
+
users: {
|
|
29
|
+
policies: [
|
|
30
|
+
{
|
|
31
|
+
type: 'allow',
|
|
32
|
+
operation: 'read',
|
|
33
|
+
name: 'users_read_own',
|
|
34
|
+
condition: () => true, // ORM-side condition
|
|
35
|
+
using: 'id = rls_current_user_id()::integer', // Native PostgreSQL
|
|
36
|
+
role: 'authenticated',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Generate PostgreSQL Statements
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { PostgresRLSGenerator } from '@kysera/rls/native';
|
|
47
|
+
|
|
48
|
+
const generator = new PostgresRLSGenerator();
|
|
49
|
+
|
|
50
|
+
// Generate RLS policies
|
|
51
|
+
const statements = generator.generateStatements(rlsSchema, {
|
|
52
|
+
schemaName: 'public',
|
|
53
|
+
policyPrefix: 'app_rls',
|
|
54
|
+
force: true, // Force RLS on table owners
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Generate context functions
|
|
58
|
+
const contextFunctions = generator.generateContextFunctions();
|
|
59
|
+
|
|
60
|
+
// Generate cleanup statements
|
|
61
|
+
const dropStatements = generator.generateDropStatements(rlsSchema, {
|
|
62
|
+
schemaName: 'public',
|
|
63
|
+
policyPrefix: 'app_rls',
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Generate Kysely Migration
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { RLSMigrationGenerator } from '@kysera/rls/native';
|
|
71
|
+
|
|
72
|
+
const migrationGenerator = new RLSMigrationGenerator();
|
|
73
|
+
|
|
74
|
+
const migrationContent = migrationGenerator.generateMigration(rlsSchema, {
|
|
75
|
+
name: 'setup_rls',
|
|
76
|
+
schemaName: 'public',
|
|
77
|
+
policyPrefix: 'app_rls',
|
|
78
|
+
includeContextFunctions: true,
|
|
79
|
+
force: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Get suggested filename with timestamp
|
|
83
|
+
const filename = migrationGenerator.generateFilename('setup_rls');
|
|
84
|
+
// Example: 20231208_123456_setup_rls.ts
|
|
85
|
+
|
|
86
|
+
// Write to migrations directory
|
|
87
|
+
import fs from 'fs';
|
|
88
|
+
fs.writeFileSync(`migrations/${filename}`, migrationContent);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 4. Sync Context to PostgreSQL Session
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { syncContextToPostgres, clearPostgresContext } from '@kysera/rls/native';
|
|
95
|
+
import { Kysely } from 'kysely';
|
|
96
|
+
|
|
97
|
+
const db = new Kysely<Database>({ ... });
|
|
98
|
+
|
|
99
|
+
// At the start of each request/transaction
|
|
100
|
+
await syncContextToPostgres(db, {
|
|
101
|
+
userId: 123,
|
|
102
|
+
tenantId: 'tenant-uuid',
|
|
103
|
+
roles: ['user', 'admin'],
|
|
104
|
+
permissions: ['read:posts', 'write:posts'],
|
|
105
|
+
isSystem: false,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Execute queries - RLS policies will be enforced
|
|
109
|
+
const users = await db.selectFrom('users').selectAll().execute();
|
|
110
|
+
|
|
111
|
+
// Clear context when done (optional, resets on connection close)
|
|
112
|
+
await clearPostgresContext(db);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Policy Definition
|
|
116
|
+
|
|
117
|
+
### Native RLS Fields
|
|
118
|
+
|
|
119
|
+
Extend your Kysera policy definitions with native PostgreSQL support:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
{
|
|
123
|
+
type: 'allow' | 'deny', // 'deny' becomes RESTRICTIVE policy
|
|
124
|
+
operation: Operation | Operation[],
|
|
125
|
+
condition: () => true, // ORM-side evaluation
|
|
126
|
+
|
|
127
|
+
// Native PostgreSQL RLS fields:
|
|
128
|
+
using?: string, // USING clause (for SELECT/UPDATE/DELETE)
|
|
129
|
+
withCheck?: string, // WITH CHECK clause (for INSERT/UPDATE)
|
|
130
|
+
role?: string, // Target role (default: 'public')
|
|
131
|
+
name?: string, // Policy name
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Policy Types
|
|
136
|
+
|
|
137
|
+
- **`allow`**: Maps to `AS PERMISSIVE` policy
|
|
138
|
+
- **`deny`**: Maps to `AS RESTRICTIVE` policy (takes precedence)
|
|
139
|
+
- **`filter`**: ORM-only, not generated as native RLS
|
|
140
|
+
- **`validate`**: ORM-only, not generated as native RLS
|
|
141
|
+
|
|
142
|
+
### Operations
|
|
143
|
+
|
|
144
|
+
| Kysera | PostgreSQL |
|
|
145
|
+
|--------|------------|
|
|
146
|
+
| `read` | `SELECT` |
|
|
147
|
+
| `create` | `INSERT` |
|
|
148
|
+
| `update` | `UPDATE` |
|
|
149
|
+
| `delete` | `DELETE` |
|
|
150
|
+
| `all` | `ALL` |
|
|
151
|
+
|
|
152
|
+
## Context Functions
|
|
153
|
+
|
|
154
|
+
The generator creates these STABLE PostgreSQL functions for optimal performance:
|
|
155
|
+
|
|
156
|
+
```sql
|
|
157
|
+
-- Get current user ID
|
|
158
|
+
rls_current_user_id() -> text
|
|
159
|
+
|
|
160
|
+
-- Get current tenant ID
|
|
161
|
+
rls_current_tenant_id() -> uuid
|
|
162
|
+
|
|
163
|
+
-- Get current user roles
|
|
164
|
+
rls_current_roles() -> text[]
|
|
165
|
+
|
|
166
|
+
-- Check if user has role
|
|
167
|
+
rls_has_role(role_name text) -> boolean
|
|
168
|
+
|
|
169
|
+
-- Get current permissions
|
|
170
|
+
rls_current_permissions() -> text[]
|
|
171
|
+
|
|
172
|
+
-- Check if user has permission
|
|
173
|
+
rls_has_permission(permission_name text) -> boolean
|
|
174
|
+
|
|
175
|
+
-- Check if this is a system account
|
|
176
|
+
rls_is_system() -> boolean
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
These functions read from PostgreSQL session variables set by `syncContextToPostgres()`.
|
|
180
|
+
|
|
181
|
+
## Examples
|
|
182
|
+
|
|
183
|
+
### Multi-Tenant Application
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
const rlsSchema: RLSSchema<Database> = {
|
|
187
|
+
posts: {
|
|
188
|
+
policies: [
|
|
189
|
+
{
|
|
190
|
+
type: 'allow',
|
|
191
|
+
operation: 'read',
|
|
192
|
+
name: 'posts_read_tenant',
|
|
193
|
+
condition: () => true,
|
|
194
|
+
using: 'tenant_id = rls_current_tenant_id()',
|
|
195
|
+
role: 'authenticated',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
type: 'allow',
|
|
199
|
+
operation: 'create',
|
|
200
|
+
name: 'posts_create_own',
|
|
201
|
+
condition: () => true,
|
|
202
|
+
withCheck: 'user_id = rls_current_user_id()::integer AND tenant_id = rls_current_tenant_id()',
|
|
203
|
+
role: 'authenticated',
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
defaultDeny: true,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Role-Based Access Control
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const rlsSchema: RLSSchema<Database> = {
|
|
215
|
+
admin_settings: {
|
|
216
|
+
policies: [
|
|
217
|
+
{
|
|
218
|
+
type: 'allow',
|
|
219
|
+
operation: 'all',
|
|
220
|
+
name: 'admin_full_access',
|
|
221
|
+
condition: () => true,
|
|
222
|
+
using: 'rls_has_role(\'admin\')',
|
|
223
|
+
role: 'authenticated',
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
type: 'deny',
|
|
227
|
+
operation: 'all',
|
|
228
|
+
name: 'deny_non_admin',
|
|
229
|
+
condition: () => true,
|
|
230
|
+
using: 'NOT rls_has_role(\'admin\')',
|
|
231
|
+
role: 'authenticated',
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
defaultDeny: true,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### System Bypass
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
{
|
|
243
|
+
type: 'allow',
|
|
244
|
+
operation: 'all',
|
|
245
|
+
name: 'system_bypass',
|
|
246
|
+
condition: () => true,
|
|
247
|
+
using: 'rls_is_system()',
|
|
248
|
+
role: 'authenticated',
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Performance Considerations
|
|
253
|
+
|
|
254
|
+
1. **STABLE Functions**: All context functions are marked as `STABLE`, allowing PostgreSQL's query planner to optimize policy evaluation.
|
|
255
|
+
|
|
256
|
+
2. **Session Variables**: Context is stored in PostgreSQL session variables (`set_config`) for fast access without database lookups.
|
|
257
|
+
|
|
258
|
+
3. **Policy Ordering**: Policies run in order of priority (higher first). Deny policies have default priority 100.
|
|
259
|
+
|
|
260
|
+
4. **Index Support**: Create indexes on columns used in RLS policies for better performance:
|
|
261
|
+
|
|
262
|
+
```sql
|
|
263
|
+
CREATE INDEX idx_posts_tenant ON posts(tenant_id);
|
|
264
|
+
CREATE INDEX idx_posts_user ON posts(user_id);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Migration Workflow
|
|
268
|
+
|
|
269
|
+
1. **Generate Migration**:
|
|
270
|
+
```bash
|
|
271
|
+
npx tsx -e "import { RLSMigrationGenerator } from './src/native'; ..."
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
2. **Review Generated SQL**: Check migration file before applying
|
|
275
|
+
|
|
276
|
+
3. **Run Migration**:
|
|
277
|
+
```bash
|
|
278
|
+
npx kysely migrate:latest
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
4. **Test Policies**: Verify RLS policies work as expected
|
|
282
|
+
|
|
283
|
+
5. **Rollback if Needed**:
|
|
284
|
+
```bash
|
|
285
|
+
npx kysely migrate:down
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## API Reference
|
|
289
|
+
|
|
290
|
+
### `PostgresRLSGenerator`
|
|
291
|
+
|
|
292
|
+
#### Methods
|
|
293
|
+
|
|
294
|
+
- `generateStatements(schema, options)`: Generate RLS policy statements
|
|
295
|
+
- `generateContextFunctions()`: Generate context function SQL
|
|
296
|
+
- `generateDropStatements(schema, options)`: Generate cleanup statements
|
|
297
|
+
|
|
298
|
+
### `RLSMigrationGenerator`
|
|
299
|
+
|
|
300
|
+
#### Methods
|
|
301
|
+
|
|
302
|
+
- `generateMigration(schema, options)`: Generate Kysely migration file content
|
|
303
|
+
- `generateFilename(name)`: Generate timestamped migration filename
|
|
304
|
+
|
|
305
|
+
### `syncContextToPostgres(db, context)`
|
|
306
|
+
|
|
307
|
+
Sync RLS context to PostgreSQL session variables.
|
|
308
|
+
|
|
309
|
+
### `clearPostgresContext(db)`
|
|
310
|
+
|
|
311
|
+
Clear RLS context from PostgreSQL session.
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { RLSSchema } from '../policy/types.js';
|
|
2
|
+
import { PostgresRLSGenerator, type PostgresRLSOptions } from './postgres.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for migration generation
|
|
6
|
+
*/
|
|
7
|
+
export interface MigrationOptions extends PostgresRLSOptions {
|
|
8
|
+
/** Migration name */
|
|
9
|
+
name?: string;
|
|
10
|
+
/** Include context functions in migration */
|
|
11
|
+
includeContextFunctions?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* RLS Migration Generator
|
|
16
|
+
* Generates Kysely migration files for RLS policies
|
|
17
|
+
*/
|
|
18
|
+
export class RLSMigrationGenerator {
|
|
19
|
+
private generator = new PostgresRLSGenerator();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate migration file content
|
|
23
|
+
*/
|
|
24
|
+
generateMigration<DB>(
|
|
25
|
+
schema: RLSSchema<DB>,
|
|
26
|
+
options: MigrationOptions = {}
|
|
27
|
+
): string {
|
|
28
|
+
const {
|
|
29
|
+
name = 'rls_policies',
|
|
30
|
+
includeContextFunctions = true,
|
|
31
|
+
...generatorOptions
|
|
32
|
+
} = options;
|
|
33
|
+
|
|
34
|
+
const upStatements = this.generator.generateStatements(schema, generatorOptions);
|
|
35
|
+
const downStatements = this.generator.generateDropStatements(schema, generatorOptions);
|
|
36
|
+
|
|
37
|
+
const contextFunctions = includeContextFunctions
|
|
38
|
+
? this.generator.generateContextFunctions()
|
|
39
|
+
: '';
|
|
40
|
+
|
|
41
|
+
return `import { Kysely, sql } from 'kysely';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Migration: ${name}
|
|
45
|
+
* Generated by @kysera/rls
|
|
46
|
+
*
|
|
47
|
+
* This migration sets up Row-Level Security policies for the database.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
51
|
+
${includeContextFunctions ? ` // Create RLS context functions
|
|
52
|
+
await sql.raw(\`${this.escapeTemplate(contextFunctions)}\`).execute(db);
|
|
53
|
+
|
|
54
|
+
` : ''} // Enable RLS and create policies
|
|
55
|
+
${upStatements.map(s => ` await sql.raw(\`${this.escapeTemplate(s)}\`).execute(db);`).join('\n')}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
59
|
+
${downStatements.map(s => ` await sql.raw(\`${this.escapeTemplate(s)}\`).execute(db);`).join('\n')}
|
|
60
|
+
${includeContextFunctions ? `
|
|
61
|
+
// Drop RLS context functions
|
|
62
|
+
await sql.raw(\`
|
|
63
|
+
DROP FUNCTION IF EXISTS rls_current_user_id();
|
|
64
|
+
DROP FUNCTION IF EXISTS rls_current_tenant_id();
|
|
65
|
+
DROP FUNCTION IF EXISTS rls_current_roles();
|
|
66
|
+
DROP FUNCTION IF EXISTS rls_has_role(text);
|
|
67
|
+
DROP FUNCTION IF EXISTS rls_current_permissions();
|
|
68
|
+
DROP FUNCTION IF EXISTS rls_has_permission(text);
|
|
69
|
+
DROP FUNCTION IF EXISTS rls_is_system();
|
|
70
|
+
\`).execute(db);` : ''}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Escape template literal for embedding in string
|
|
77
|
+
*/
|
|
78
|
+
private escapeTemplate(str: string): string {
|
|
79
|
+
return str.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate migration filename with timestamp
|
|
84
|
+
*/
|
|
85
|
+
generateFilename(name: string = 'rls_policies'): string {
|
|
86
|
+
const timestamp = new Date().toISOString()
|
|
87
|
+
.replace(/[-:]/g, '')
|
|
88
|
+
.replace('T', '_')
|
|
89
|
+
.replace(/\..+/, '');
|
|
90
|
+
return `${timestamp}_${name}.ts`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
import type { RLSSchema, TableRLSConfig, PolicyDefinition, Operation } from '../policy/types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for PostgreSQL RLS generation
|
|
7
|
+
*/
|
|
8
|
+
export interface PostgresRLSOptions {
|
|
9
|
+
/** Force RLS on table owners */
|
|
10
|
+
force?: boolean;
|
|
11
|
+
/** Schema name (default: public) */
|
|
12
|
+
schemaName?: string;
|
|
13
|
+
/** Prefix for generated policy names */
|
|
14
|
+
policyPrefix?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* PostgreSQL RLS Generator
|
|
19
|
+
* Generates native PostgreSQL RLS statements from Kysera RLS schema
|
|
20
|
+
*/
|
|
21
|
+
export class PostgresRLSGenerator {
|
|
22
|
+
/**
|
|
23
|
+
* Generate all PostgreSQL RLS statements from schema
|
|
24
|
+
*/
|
|
25
|
+
generateStatements<DB>(
|
|
26
|
+
schema: RLSSchema<DB>,
|
|
27
|
+
options: PostgresRLSOptions = {}
|
|
28
|
+
): string[] {
|
|
29
|
+
const {
|
|
30
|
+
force = true,
|
|
31
|
+
schemaName = 'public',
|
|
32
|
+
policyPrefix = 'rls',
|
|
33
|
+
} = options;
|
|
34
|
+
|
|
35
|
+
const statements: string[] = [];
|
|
36
|
+
|
|
37
|
+
for (const [table, config] of Object.entries(schema)) {
|
|
38
|
+
if (!config) continue;
|
|
39
|
+
|
|
40
|
+
const qualifiedTable = `${schemaName}.${table}`;
|
|
41
|
+
const tableConfig = config as TableRLSConfig;
|
|
42
|
+
|
|
43
|
+
// Enable RLS on table
|
|
44
|
+
statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY;`);
|
|
45
|
+
|
|
46
|
+
if (force) {
|
|
47
|
+
statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY;`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate policies
|
|
51
|
+
let policyIndex = 0;
|
|
52
|
+
for (const policy of tableConfig.policies) {
|
|
53
|
+
const policyName = policy.name ?? `${policyPrefix}_${table}_${policy.type}_${policyIndex++}`;
|
|
54
|
+
const policySQL = this.generatePolicy(qualifiedTable, policyName, policy);
|
|
55
|
+
if (policySQL) {
|
|
56
|
+
statements.push(policySQL);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return statements;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate a single policy statement
|
|
66
|
+
*/
|
|
67
|
+
private generatePolicy(
|
|
68
|
+
table: string,
|
|
69
|
+
name: string,
|
|
70
|
+
policy: PolicyDefinition
|
|
71
|
+
): string | null {
|
|
72
|
+
// Skip filter policies (they're ORM-only)
|
|
73
|
+
if (policy.type === 'filter' || policy.type === 'validate') {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Need USING or WITH CHECK clause for native RLS
|
|
78
|
+
if (!policy.using && !policy.withCheck) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parts: string[] = [
|
|
83
|
+
`CREATE POLICY "${name}"`,
|
|
84
|
+
`ON ${table}`,
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Policy type
|
|
88
|
+
if (policy.type === 'deny') {
|
|
89
|
+
parts.push('AS RESTRICTIVE');
|
|
90
|
+
} else {
|
|
91
|
+
parts.push('AS PERMISSIVE');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Target role
|
|
95
|
+
parts.push(`TO ${policy.role ?? 'public'}`);
|
|
96
|
+
|
|
97
|
+
// Operation
|
|
98
|
+
parts.push(`FOR ${this.mapOperation(policy.operation)}`);
|
|
99
|
+
|
|
100
|
+
// USING clause
|
|
101
|
+
if (policy.using) {
|
|
102
|
+
parts.push(`USING (${policy.using})`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// WITH CHECK clause
|
|
106
|
+
if (policy.withCheck) {
|
|
107
|
+
parts.push(`WITH CHECK (${policy.withCheck})`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return parts.join('\n ') + ';';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Map Kysera operation to PostgreSQL operation
|
|
115
|
+
*/
|
|
116
|
+
private mapOperation(operation: Operation | Operation[]): string {
|
|
117
|
+
if (Array.isArray(operation)) {
|
|
118
|
+
if (operation.length === 0) {
|
|
119
|
+
return 'ALL';
|
|
120
|
+
}
|
|
121
|
+
if (operation.length === 4 || operation.includes('all')) {
|
|
122
|
+
return 'ALL';
|
|
123
|
+
}
|
|
124
|
+
// PostgreSQL doesn't support multiple operations in one policy
|
|
125
|
+
// Return first operation
|
|
126
|
+
return this.mapSingleOperation(operation[0]!);
|
|
127
|
+
}
|
|
128
|
+
return this.mapSingleOperation(operation);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Map single operation
|
|
133
|
+
*/
|
|
134
|
+
private mapSingleOperation(op: Operation): string {
|
|
135
|
+
switch (op) {
|
|
136
|
+
case 'read': return 'SELECT';
|
|
137
|
+
case 'create': return 'INSERT';
|
|
138
|
+
case 'update': return 'UPDATE';
|
|
139
|
+
case 'delete': return 'DELETE';
|
|
140
|
+
case 'all': return 'ALL';
|
|
141
|
+
default: return 'ALL';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Generate context-setting functions for PostgreSQL
|
|
147
|
+
* These functions should be STABLE for optimal performance
|
|
148
|
+
*/
|
|
149
|
+
generateContextFunctions(): string {
|
|
150
|
+
return `
|
|
151
|
+
-- RLS Context Functions (STABLE for query planner optimization)
|
|
152
|
+
-- These functions read session variables set by the application
|
|
153
|
+
|
|
154
|
+
CREATE OR REPLACE FUNCTION rls_current_user_id()
|
|
155
|
+
RETURNS text
|
|
156
|
+
LANGUAGE SQL STABLE
|
|
157
|
+
AS $$ SELECT current_setting('app.user_id', true) $$;
|
|
158
|
+
|
|
159
|
+
CREATE OR REPLACE FUNCTION rls_current_tenant_id()
|
|
160
|
+
RETURNS uuid
|
|
161
|
+
LANGUAGE SQL STABLE
|
|
162
|
+
AS $$ SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid $$;
|
|
163
|
+
|
|
164
|
+
CREATE OR REPLACE FUNCTION rls_current_roles()
|
|
165
|
+
RETURNS text[]
|
|
166
|
+
LANGUAGE SQL STABLE
|
|
167
|
+
AS $$ SELECT string_to_array(COALESCE(current_setting('app.roles', true), ''), ',') $$;
|
|
168
|
+
|
|
169
|
+
CREATE OR REPLACE FUNCTION rls_has_role(role_name text)
|
|
170
|
+
RETURNS boolean
|
|
171
|
+
LANGUAGE SQL STABLE
|
|
172
|
+
AS $$ SELECT role_name = ANY(rls_current_roles()) $$;
|
|
173
|
+
|
|
174
|
+
CREATE OR REPLACE FUNCTION rls_current_permissions()
|
|
175
|
+
RETURNS text[]
|
|
176
|
+
LANGUAGE SQL STABLE
|
|
177
|
+
AS $$ SELECT string_to_array(COALESCE(current_setting('app.permissions', true), ''), ',') $$;
|
|
178
|
+
|
|
179
|
+
CREATE OR REPLACE FUNCTION rls_has_permission(permission_name text)
|
|
180
|
+
RETURNS boolean
|
|
181
|
+
LANGUAGE SQL STABLE
|
|
182
|
+
AS $$ SELECT permission_name = ANY(rls_current_permissions()) $$;
|
|
183
|
+
|
|
184
|
+
CREATE OR REPLACE FUNCTION rls_is_system()
|
|
185
|
+
RETURNS boolean
|
|
186
|
+
LANGUAGE SQL STABLE
|
|
187
|
+
AS $$ SELECT COALESCE(current_setting('app.is_system', true), 'false')::boolean $$;
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate DROP statements for cleaning up
|
|
193
|
+
*/
|
|
194
|
+
generateDropStatements<DB>(
|
|
195
|
+
schema: RLSSchema<DB>,
|
|
196
|
+
options: PostgresRLSOptions = {}
|
|
197
|
+
): string[] {
|
|
198
|
+
const { schemaName = 'public', policyPrefix = 'rls' } = options;
|
|
199
|
+
const statements: string[] = [];
|
|
200
|
+
|
|
201
|
+
for (const table of Object.keys(schema)) {
|
|
202
|
+
const qualifiedTable = `${schemaName}.${table}`;
|
|
203
|
+
|
|
204
|
+
// Drop all policies with prefix
|
|
205
|
+
statements.push(
|
|
206
|
+
`DO $$ BEGIN
|
|
207
|
+
EXECUTE (
|
|
208
|
+
SELECT string_agg('DROP POLICY IF EXISTS ' || quote_ident(policyname) || ' ON ${qualifiedTable};', E'\\n')
|
|
209
|
+
FROM pg_policies
|
|
210
|
+
WHERE tablename = '${table}'
|
|
211
|
+
AND schemaname = '${schemaName}'
|
|
212
|
+
AND policyname LIKE '${policyPrefix}_%'
|
|
213
|
+
);
|
|
214
|
+
END $$;`
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Disable RLS
|
|
218
|
+
statements.push(`ALTER TABLE ${qualifiedTable} DISABLE ROW LEVEL SECURITY;`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return statements;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Sync RLS context to PostgreSQL session settings
|
|
227
|
+
* Call this at the start of each request/transaction
|
|
228
|
+
*/
|
|
229
|
+
export async function syncContextToPostgres<DB>(
|
|
230
|
+
db: Kysely<DB>,
|
|
231
|
+
context: {
|
|
232
|
+
userId: string | number;
|
|
233
|
+
tenantId?: string | number;
|
|
234
|
+
roles?: string[];
|
|
235
|
+
permissions?: string[];
|
|
236
|
+
isSystem?: boolean;
|
|
237
|
+
}
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const { userId, tenantId, roles, permissions, isSystem } = context;
|
|
240
|
+
|
|
241
|
+
await sql`
|
|
242
|
+
SELECT
|
|
243
|
+
set_config('app.user_id', ${String(userId)}, true),
|
|
244
|
+
set_config('app.tenant_id', ${tenantId ? String(tenantId) : ''}, true),
|
|
245
|
+
set_config('app.roles', ${(roles ?? []).join(',')}, true),
|
|
246
|
+
set_config('app.permissions', ${(permissions ?? []).join(',')}, true),
|
|
247
|
+
set_config('app.is_system', ${isSystem ? 'true' : 'false'}, true)
|
|
248
|
+
`.execute(db);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Clear RLS context from PostgreSQL session
|
|
253
|
+
*/
|
|
254
|
+
export async function clearPostgresContext<DB>(db: Kysely<DB>): Promise<void> {
|
|
255
|
+
await sql`
|
|
256
|
+
SELECT
|
|
257
|
+
set_config('app.user_id', '', true),
|
|
258
|
+
set_config('app.tenant_id', '', true),
|
|
259
|
+
set_config('app.roles', '', true),
|
|
260
|
+
set_config('app.permissions', '', true),
|
|
261
|
+
set_config('app.is_system', 'false', true)
|
|
262
|
+
`.execute(db);
|
|
263
|
+
}
|