@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.
@@ -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,11 @@
1
+ export {
2
+ PostgresRLSGenerator,
3
+ syncContextToPostgres,
4
+ clearPostgresContext,
5
+ type PostgresRLSOptions,
6
+ } from './postgres.js';
7
+
8
+ export {
9
+ RLSMigrationGenerator,
10
+ type MigrationOptions,
11
+ } from './migration.js';
@@ -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
+ }