@kysera/rls 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +390 -276
- package/dist/index.d.ts +95 -19
- package/dist/index.js +219 -130
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/native/index.js +3 -14
- package/dist/native/index.js.map +1 -1
- package/dist/{types-6eCXh_Jd.d.ts → types-Dowjd6zG.d.ts} +3 -3
- package/package.json +16 -7
- package/src/context/index.ts +4 -4
- package/src/context/manager.ts +45 -45
- package/src/context/storage.ts +3 -3
- package/src/context/types.ts +1 -5
- package/src/errors.ts +62 -77
- package/src/index.ts +13 -13
- package/src/native/README.md +49 -46
- package/src/native/index.ts +3 -6
- package/src/native/migration.ts +29 -27
- package/src/native/postgres.ts +63 -74
- package/src/plugin.ts +306 -159
- package/src/policy/builder.ts +46 -33
- package/src/policy/index.ts +4 -4
- package/src/policy/registry.ts +100 -105
- package/src/policy/schema.ts +58 -71
- package/src/policy/types.ts +58 -58
- package/src/transformer/index.ts +2 -2
- package/src/transformer/mutation.ts +95 -98
- package/src/transformer/select.ts +59 -43
- package/src/utils/helpers.ts +57 -50
- package/src/utils/index.ts +13 -2
- package/src/utils/type-utils.ts +155 -0
- package/src/version.ts +7 -0
package/src/plugin.ts
CHANGED
|
@@ -10,55 +10,121 @@
|
|
|
10
10
|
* @module @kysera/rls
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { Plugin, QueryBuilderContext } from '@kysera/executor'
|
|
14
|
-
import { getRawDb } from '@kysera/executor'
|
|
15
|
-
import type { Kysely } from 'kysely'
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
13
|
+
import type { Plugin, QueryBuilderContext, BaseRepositoryLike } from '@kysera/executor'
|
|
14
|
+
import { getRawDb, isRepositoryLike } from '@kysera/executor'
|
|
15
|
+
import type { Kysely } from 'kysely'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
import type { RLSSchema, Operation } from './policy/types.js'
|
|
18
|
+
import { PolicyRegistry } from './policy/registry.js'
|
|
19
|
+
import { SelectTransformer } from './transformer/select.js'
|
|
20
|
+
import { MutationGuard } from './transformer/mutation.js'
|
|
21
|
+
import { rlsContext } from './context/manager.js'
|
|
22
|
+
import { VERSION } from './version.js'
|
|
23
|
+
import { RLSContextError, RLSPolicyViolation, RLSError, RLSErrorCodes } from './errors.js'
|
|
24
|
+
import { silentLogger, type KyseraLogger } from '@kysera/core'
|
|
25
|
+
import {
|
|
26
|
+
transformQueryBuilder,
|
|
27
|
+
selectFromDynamicTable,
|
|
28
|
+
whereIdEquals,
|
|
29
|
+
hasRawDb as hasRawDbUtil,
|
|
30
|
+
applyWhereCondition,
|
|
31
|
+
createRawCondition
|
|
32
|
+
} from './utils/type-utils.js'
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* RLS Plugin configuration options
|
|
26
36
|
*/
|
|
27
37
|
export interface RLSPluginOptions<DB = unknown> {
|
|
28
38
|
/** RLS policy schema */
|
|
29
|
-
schema: RLSSchema<DB
|
|
39
|
+
schema: RLSSchema<DB>
|
|
30
40
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Tables to exclude from RLS (always bypass policies)
|
|
43
|
+
* Replaces the deprecated `skipTables` option for consistency with other plugins.
|
|
44
|
+
* @default []
|
|
45
|
+
*/
|
|
46
|
+
excludeTables?: string[]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @deprecated Use `excludeTables` instead for consistency with other Kysera plugins
|
|
50
|
+
* @default []
|
|
51
|
+
*/
|
|
52
|
+
skipTables?: string[]
|
|
33
53
|
|
|
34
54
|
/** Roles that bypass RLS entirely (e.g., ['admin', 'superuser']) */
|
|
35
|
-
bypassRoles?: string[]
|
|
55
|
+
bypassRoles?: string[]
|
|
36
56
|
|
|
37
57
|
/** Logger instance for RLS operations */
|
|
38
|
-
logger?: KyseraLogger
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
|
|
58
|
+
logger?: KyseraLogger
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Require RLS context for all operations (throws if missing)
|
|
62
|
+
*
|
|
63
|
+
* **Security**: Defaults to `true` for secure-by-default behavior.
|
|
64
|
+
* When `true`, missing RLS context throws RLSContextError, preventing
|
|
65
|
+
* unfiltered database access which could expose sensitive data.
|
|
66
|
+
*
|
|
67
|
+
* Only set to `false` if you explicitly want to allow queries without
|
|
68
|
+
* RLS context (not recommended in production).
|
|
69
|
+
*
|
|
70
|
+
* @default true
|
|
71
|
+
* @see allowUnfilteredQueries for explicit unfiltered query control
|
|
72
|
+
*/
|
|
73
|
+
requireContext?: boolean
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Allow unfiltered queries when RLS context is missing
|
|
77
|
+
*
|
|
78
|
+
* **SECURITY WARNING**: Setting this to `true` allows database queries
|
|
79
|
+
* to execute without RLS filtering when context is missing. This can
|
|
80
|
+
* expose sensitive data across tenant boundaries or user permissions.
|
|
81
|
+
*
|
|
82
|
+
* Only enable this if you:
|
|
83
|
+
* 1. Understand the security implications
|
|
84
|
+
* 2. Have other security controls in place
|
|
85
|
+
* 3. Are running background jobs or system operations that don't have user context
|
|
86
|
+
*
|
|
87
|
+
* When both `requireContext: false` and `allowUnfilteredQueries: false`:
|
|
88
|
+
* - Missing context logs a warning and returns empty results
|
|
89
|
+
*
|
|
90
|
+
* @default false (secure-by-default)
|
|
91
|
+
*/
|
|
92
|
+
allowUnfilteredQueries?: boolean
|
|
42
93
|
|
|
43
94
|
/** Enable audit logging of policy decisions */
|
|
44
|
-
auditDecisions?: boolean
|
|
95
|
+
auditDecisions?: boolean
|
|
45
96
|
|
|
46
97
|
/** Custom error handler for policy violations */
|
|
47
|
-
onViolation?: (violation: RLSPolicyViolation) => void
|
|
98
|
+
onViolation?: (violation: RLSPolicyViolation) => void
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Primary key column name for row lookups.
|
|
102
|
+
* @default 'id'
|
|
103
|
+
*/
|
|
104
|
+
primaryKeyColumn?: string
|
|
48
105
|
}
|
|
49
106
|
|
|
50
107
|
/**
|
|
51
|
-
*
|
|
108
|
+
* Zod schema for RLSPluginOptions
|
|
109
|
+
* Used for validation and configuration in the kysera-cli.
|
|
110
|
+
* Note: 'schema' and 'onViolation' are not included as they are complex runtime objects.
|
|
111
|
+
*/
|
|
112
|
+
export const RLSPluginOptionsSchema = z.object({
|
|
113
|
+
excludeTables: z.array(z.string()).optional(),
|
|
114
|
+
skipTables: z.array(z.string()).optional(), // deprecated but kept for backward compatibility
|
|
115
|
+
bypassRoles: z.array(z.string()).optional(),
|
|
116
|
+
requireContext: z.boolean().optional(),
|
|
117
|
+
allowUnfilteredQueries: z.boolean().optional(),
|
|
118
|
+
auditDecisions: z.boolean().optional(),
|
|
119
|
+
primaryKeyColumn: z.string().optional()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Base repository interface for type safety.
|
|
124
|
+
* Type alias for BaseRepositoryLike from @kysera/executor with concrete DB type.
|
|
52
125
|
* @internal
|
|
53
126
|
*/
|
|
54
|
-
|
|
55
|
-
tableName: string;
|
|
56
|
-
executor: Kysely<Record<string, unknown>>;
|
|
57
|
-
findById?: (id: unknown) => Promise<unknown>;
|
|
58
|
-
create?: (data: unknown) => Promise<unknown>;
|
|
59
|
-
update?: (id: unknown, data: unknown) => Promise<unknown>;
|
|
60
|
-
delete?: (id: unknown) => Promise<unknown>;
|
|
61
|
-
}
|
|
127
|
+
type BaseRepository = BaseRepositoryLike<Record<string, unknown>>
|
|
62
128
|
|
|
63
129
|
/**
|
|
64
130
|
* Create RLS plugin for Kysera
|
|
@@ -110,22 +176,36 @@ interface BaseRepository {
|
|
|
110
176
|
export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
111
177
|
const {
|
|
112
178
|
schema,
|
|
113
|
-
|
|
179
|
+
excludeTables: excludeTablesOption,
|
|
180
|
+
skipTables: skipTablesOption,
|
|
114
181
|
bypassRoles = [],
|
|
115
182
|
logger = silentLogger,
|
|
116
|
-
requireContext =
|
|
183
|
+
requireContext = true, // SECURITY: Changed to true for secure-by-default (CRIT-2 fix)
|
|
184
|
+
allowUnfilteredQueries = false, // SECURITY: Explicit opt-in for unfiltered queries
|
|
117
185
|
auditDecisions = false,
|
|
118
186
|
onViolation,
|
|
119
|
-
|
|
187
|
+
primaryKeyColumn = 'id'
|
|
188
|
+
} = options
|
|
189
|
+
|
|
190
|
+
// Backward compatibility: support both excludeTables and skipTables
|
|
191
|
+
// excludeTables takes precedence if both are provided
|
|
192
|
+
const excludeTables = excludeTablesOption ?? skipTablesOption ?? []
|
|
193
|
+
|
|
194
|
+
// Warn if deprecated skipTables is used
|
|
195
|
+
if (skipTablesOption !== undefined && excludeTablesOption === undefined) {
|
|
196
|
+
logger.warn?.(
|
|
197
|
+
'[RLS] The "skipTables" option is deprecated. Use "excludeTables" instead for consistency with other Kysera plugins.'
|
|
198
|
+
)
|
|
199
|
+
}
|
|
120
200
|
|
|
121
201
|
// Registry and transformers (initialized in onInit)
|
|
122
|
-
let registry: PolicyRegistry<DB
|
|
123
|
-
let selectTransformer: SelectTransformer<DB
|
|
124
|
-
let mutationGuard: MutationGuard<DB
|
|
202
|
+
let registry: PolicyRegistry<DB>
|
|
203
|
+
let selectTransformer: SelectTransformer<DB>
|
|
204
|
+
let mutationGuard: MutationGuard<DB>
|
|
125
205
|
|
|
126
206
|
return {
|
|
127
207
|
name: '@kysera/rls',
|
|
128
|
-
version:
|
|
208
|
+
version: VERSION,
|
|
129
209
|
|
|
130
210
|
// Run after soft-delete (priority 0), before audit
|
|
131
211
|
priority: 50,
|
|
@@ -136,22 +216,33 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
136
216
|
/**
|
|
137
217
|
* Initialize plugin - compile policies
|
|
138
218
|
*/
|
|
139
|
-
|
|
219
|
+
onInit<TDB>(_executor: Kysely<TDB>): void {
|
|
140
220
|
logger.info?.('[RLS] Initializing RLS plugin', {
|
|
141
221
|
tables: Object.keys(schema).length,
|
|
142
|
-
|
|
143
|
-
bypassRoles: bypassRoles.length
|
|
144
|
-
})
|
|
222
|
+
excludeTables: excludeTables.length,
|
|
223
|
+
bypassRoles: bypassRoles.length
|
|
224
|
+
})
|
|
145
225
|
|
|
146
226
|
// Create and compile registry
|
|
147
|
-
|
|
148
|
-
|
|
227
|
+
// Type assertion: The plugin is configured with a specific DB schema,
|
|
228
|
+
// but onInit receives a generic TDB. We use the schema's DB type.
|
|
229
|
+
registry = new PolicyRegistry<DB>(schema)
|
|
230
|
+
registry.validate()
|
|
149
231
|
|
|
150
232
|
// Create transformers
|
|
151
|
-
selectTransformer = new SelectTransformer<DB>(registry)
|
|
152
|
-
mutationGuard = new MutationGuard<DB>(registry)
|
|
233
|
+
selectTransformer = new SelectTransformer<DB>(registry)
|
|
234
|
+
mutationGuard = new MutationGuard<DB>(registry)
|
|
153
235
|
|
|
154
|
-
logger.info?.('[RLS] RLS plugin initialized successfully')
|
|
236
|
+
logger.info?.('[RLS] RLS plugin initialized successfully')
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Cleanup resources when executor is destroyed
|
|
241
|
+
*/
|
|
242
|
+
async onDestroy(): Promise<void> {
|
|
243
|
+
// Clear registry to free up memory
|
|
244
|
+
registry.clear()
|
|
245
|
+
logger.info?.('[RLS] RLS plugin destroyed, cleared policy registry')
|
|
155
246
|
},
|
|
156
247
|
|
|
157
248
|
/**
|
|
@@ -161,74 +252,111 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
161
252
|
* it applies filter policies as WHERE conditions. For mutations, it marks
|
|
162
253
|
* that RLS validation is required (performed in extendRepository).
|
|
163
254
|
*/
|
|
164
|
-
interceptQuery<QB>(
|
|
165
|
-
|
|
166
|
-
context: QueryBuilderContext
|
|
167
|
-
): QB {
|
|
168
|
-
const { operation, table, metadata } = context;
|
|
255
|
+
interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
|
|
256
|
+
const { operation, table, metadata } = context
|
|
169
257
|
|
|
170
258
|
// Skip if table is excluded
|
|
171
|
-
if (
|
|
172
|
-
logger.debug?.(`[RLS] Skipping RLS for excluded table: ${table}`)
|
|
173
|
-
return qb
|
|
259
|
+
if (excludeTables.includes(table)) {
|
|
260
|
+
logger.debug?.(`[RLS] Skipping RLS for excluded table: ${table}`)
|
|
261
|
+
return qb
|
|
174
262
|
}
|
|
175
263
|
|
|
176
264
|
// Skip if explicitly disabled via metadata
|
|
177
265
|
if (metadata['skipRLS'] === true) {
|
|
178
|
-
logger.debug?.(`[RLS] Skipping RLS (explicit skip): ${table}`)
|
|
179
|
-
return qb
|
|
266
|
+
logger.debug?.(`[RLS] Skipping RLS (explicit skip): ${table}`)
|
|
267
|
+
return qb
|
|
180
268
|
}
|
|
181
269
|
|
|
182
270
|
// Check for context
|
|
183
|
-
const ctx = rlsContext.getContextOrNull()
|
|
271
|
+
const ctx = rlsContext.getContextOrNull()
|
|
184
272
|
|
|
185
273
|
if (!ctx) {
|
|
274
|
+
// SECURITY FIX (CRIT-2): Secure-by-default behavior for missing context
|
|
186
275
|
if (requireContext) {
|
|
187
|
-
throw new RLSContextError(
|
|
276
|
+
throw new RLSContextError(
|
|
277
|
+
`RLS context required but not found for ${operation} on ${table}. ` +
|
|
278
|
+
`This prevents unfiltered database access. ` +
|
|
279
|
+
`Either provide RLS context or set 'requireContext: false' with 'allowUnfilteredQueries: true' if intentional.`
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!allowUnfilteredQueries) {
|
|
284
|
+
// Log warning and return safe empty result
|
|
285
|
+
logger.warn?.(
|
|
286
|
+
`[RLS] Missing context for ${operation} on ${table}. ` +
|
|
287
|
+
`Queries will return empty results for security. ` +
|
|
288
|
+
`Set 'allowUnfilteredQueries: true' to allow unfiltered access (not recommended).`
|
|
289
|
+
)
|
|
290
|
+
// For SELECT, apply impossible condition to return no rows
|
|
291
|
+
if (operation === 'select') {
|
|
292
|
+
return transformQueryBuilder(qb, operation, selectQb => {
|
|
293
|
+
// Apply WHERE FALSE to ensure no rows are returned
|
|
294
|
+
return applyWhereCondition(
|
|
295
|
+
selectQb,
|
|
296
|
+
createRawCondition('FALSE') as unknown as string,
|
|
297
|
+
'=',
|
|
298
|
+
true
|
|
299
|
+
) as typeof selectQb
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
// For mutations, we'll let them through but log warning
|
|
303
|
+
// The extendRepository will handle mutation checks
|
|
304
|
+
return qb
|
|
188
305
|
}
|
|
189
|
-
|
|
190
|
-
|
|
306
|
+
|
|
307
|
+
// allowUnfilteredQueries is true - allow but log warning
|
|
308
|
+
logger.warn?.(
|
|
309
|
+
`[RLS] No context for ${operation} on ${table}. ` +
|
|
310
|
+
`Allowing unfiltered query due to 'allowUnfilteredQueries: true'. ` +
|
|
311
|
+
`This may expose sensitive data.`
|
|
312
|
+
)
|
|
313
|
+
return qb
|
|
191
314
|
}
|
|
192
315
|
|
|
193
316
|
// Check if system user (bypass RLS)
|
|
194
317
|
if (ctx.auth.isSystem) {
|
|
195
|
-
logger.debug?.(`[RLS] Bypassing RLS (system user): ${table}`)
|
|
196
|
-
return qb
|
|
318
|
+
logger.debug?.(`[RLS] Bypassing RLS (system user): ${table}`)
|
|
319
|
+
return qb
|
|
197
320
|
}
|
|
198
321
|
|
|
199
322
|
// Check bypass roles
|
|
200
323
|
if (bypassRoles.some(role => ctx.auth.roles.includes(role))) {
|
|
201
|
-
logger.debug?.(`[RLS] Bypassing RLS (bypass role): ${table}`)
|
|
202
|
-
return qb
|
|
324
|
+
logger.debug?.(`[RLS] Bypassing RLS (bypass role): ${table}`)
|
|
325
|
+
return qb
|
|
203
326
|
}
|
|
204
327
|
|
|
205
328
|
// Apply SELECT filtering
|
|
206
329
|
if (operation === 'select') {
|
|
207
330
|
try {
|
|
208
|
-
const transformed =
|
|
331
|
+
const transformed = transformQueryBuilder(
|
|
332
|
+
qb,
|
|
333
|
+
operation,
|
|
334
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
335
|
+
selectQb => selectTransformer.transform(selectQb as any, table) as any
|
|
336
|
+
)
|
|
209
337
|
|
|
210
338
|
if (auditDecisions) {
|
|
211
339
|
logger.info?.('[RLS] Filter applied', {
|
|
212
340
|
table,
|
|
213
341
|
operation,
|
|
214
|
-
userId: ctx.auth.userId
|
|
215
|
-
})
|
|
342
|
+
userId: ctx.auth.userId
|
|
343
|
+
})
|
|
216
344
|
}
|
|
217
345
|
|
|
218
|
-
return transformed
|
|
346
|
+
return transformed
|
|
219
347
|
} catch (error) {
|
|
220
|
-
logger.error?.('[RLS] Error applying filter', { table, error })
|
|
221
|
-
throw error
|
|
348
|
+
logger.error?.('[RLS] Error applying filter', { table, error })
|
|
349
|
+
throw error
|
|
222
350
|
}
|
|
223
351
|
}
|
|
224
352
|
|
|
225
353
|
// For mutations, mark that RLS check is needed (done in extendRepository)
|
|
226
354
|
if (operation === 'insert' || operation === 'update' || operation === 'delete') {
|
|
227
|
-
metadata['__rlsRequired'] = true
|
|
228
|
-
metadata['__rlsTable'] = table
|
|
355
|
+
metadata['__rlsRequired'] = true
|
|
356
|
+
metadata['__rlsTable'] = table
|
|
229
357
|
}
|
|
230
358
|
|
|
231
|
-
return qb
|
|
359
|
+
return qb
|
|
232
360
|
},
|
|
233
361
|
|
|
234
362
|
/**
|
|
@@ -238,38 +366,39 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
238
366
|
* Also adds utility methods for bypassing RLS and checking access.
|
|
239
367
|
*/
|
|
240
368
|
extendRepository<T extends object>(repo: T): T {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {
|
|
245
|
-
return repo;
|
|
369
|
+
// Use the shared type guard from @kysera/executor
|
|
370
|
+
if (!isRepositoryLike(repo)) {
|
|
371
|
+
return repo
|
|
246
372
|
}
|
|
247
373
|
|
|
248
|
-
const
|
|
374
|
+
const baseRepo = repo as unknown as BaseRepository
|
|
375
|
+
|
|
376
|
+
const table = baseRepo.tableName
|
|
249
377
|
|
|
250
378
|
// Skip excluded tables
|
|
251
|
-
if (
|
|
252
|
-
|
|
379
|
+
if (excludeTables.includes(table)) {
|
|
380
|
+
logger.debug?.(`[RLS] Skipping repository extension for excluded table: ${table}`)
|
|
381
|
+
return repo
|
|
253
382
|
}
|
|
254
383
|
|
|
255
384
|
// Skip if table not in schema
|
|
256
385
|
if (!registry.hasTable(table)) {
|
|
257
|
-
logger.debug?.(`[RLS] Table "${table}" not in RLS schema, skipping`)
|
|
258
|
-
return repo
|
|
386
|
+
logger.debug?.(`[RLS] Table "${table}" not in RLS schema, skipping`)
|
|
387
|
+
return repo
|
|
259
388
|
}
|
|
260
389
|
|
|
261
|
-
logger.debug?.(`[RLS] Extending repository for table: ${table}`)
|
|
390
|
+
logger.debug?.(`[RLS] Extending repository for table: ${table}`)
|
|
262
391
|
|
|
263
392
|
// Store original methods
|
|
264
|
-
const originalCreate = baseRepo.create?.bind(baseRepo)
|
|
265
|
-
const originalUpdate = baseRepo.update?.bind(baseRepo)
|
|
266
|
-
const originalDelete = baseRepo.delete?.bind(baseRepo)
|
|
267
|
-
const originalFindById = baseRepo.findById?.bind(baseRepo)
|
|
393
|
+
const originalCreate = baseRepo.create?.bind(baseRepo)
|
|
394
|
+
const originalUpdate = baseRepo.update?.bind(baseRepo)
|
|
395
|
+
const originalDelete = baseRepo.delete?.bind(baseRepo)
|
|
396
|
+
const originalFindById = baseRepo.findById?.bind(baseRepo)
|
|
268
397
|
|
|
269
398
|
// Get raw db for internal queries that need to bypass RLS
|
|
270
399
|
// If executor doesn't have __rawDb (e.g., in tests), we'll use originalFindById
|
|
271
|
-
const rawDb = getRawDb(baseRepo.executor)
|
|
272
|
-
const
|
|
400
|
+
const rawDb = getRawDb(baseRepo.executor)
|
|
401
|
+
const hasRawDbInstance = hasRawDbUtil(baseRepo.executor)
|
|
273
402
|
|
|
274
403
|
const extendedRepo = {
|
|
275
404
|
...baseRepo,
|
|
@@ -279,36 +408,42 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
279
408
|
*/
|
|
280
409
|
async create(data: unknown): Promise<unknown> {
|
|
281
410
|
if (!originalCreate) {
|
|
282
|
-
throw new RLSError(
|
|
411
|
+
throw new RLSError(
|
|
412
|
+
'Repository does not support create operation',
|
|
413
|
+
RLSErrorCodes.RLS_POLICY_INVALID
|
|
414
|
+
)
|
|
283
415
|
}
|
|
284
416
|
|
|
285
|
-
const ctx = rlsContext.getContextOrNull()
|
|
417
|
+
const ctx = rlsContext.getContextOrNull()
|
|
286
418
|
|
|
287
419
|
// Check RLS if context exists and not system/bypass
|
|
288
|
-
if (
|
|
289
|
-
|
|
420
|
+
if (
|
|
421
|
+
ctx &&
|
|
422
|
+
!ctx.auth.isSystem &&
|
|
423
|
+
!bypassRoles.some(role => ctx.auth.roles.includes(role))
|
|
424
|
+
) {
|
|
290
425
|
try {
|
|
291
|
-
await mutationGuard.checkCreate(table, data as Record<string, unknown>)
|
|
426
|
+
await mutationGuard.checkCreate(table, data as Record<string, unknown>)
|
|
292
427
|
|
|
293
428
|
if (auditDecisions) {
|
|
294
|
-
logger.info?.('[RLS] Create allowed', { table, userId: ctx.auth.userId })
|
|
429
|
+
logger.info?.('[RLS] Create allowed', { table, userId: ctx.auth.userId })
|
|
295
430
|
}
|
|
296
431
|
} catch (error) {
|
|
297
432
|
if (error instanceof RLSPolicyViolation) {
|
|
298
|
-
onViolation?.(error)
|
|
433
|
+
onViolation?.(error)
|
|
299
434
|
if (auditDecisions) {
|
|
300
435
|
logger.warn?.('[RLS] Create denied', {
|
|
301
436
|
table,
|
|
302
437
|
userId: ctx.auth.userId,
|
|
303
438
|
reason: error.reason
|
|
304
|
-
})
|
|
439
|
+
})
|
|
305
440
|
}
|
|
306
441
|
}
|
|
307
|
-
throw error
|
|
442
|
+
throw error
|
|
308
443
|
}
|
|
309
444
|
}
|
|
310
445
|
|
|
311
|
-
return originalCreate(data)
|
|
446
|
+
return await originalCreate(data)
|
|
312
447
|
},
|
|
313
448
|
|
|
314
449
|
/**
|
|
@@ -316,34 +451,40 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
316
451
|
*/
|
|
317
452
|
async update(id: unknown, data: unknown): Promise<unknown> {
|
|
318
453
|
if (!originalUpdate) {
|
|
319
|
-
throw new RLSError(
|
|
454
|
+
throw new RLSError(
|
|
455
|
+
'Repository does not support update operation',
|
|
456
|
+
RLSErrorCodes.RLS_POLICY_INVALID
|
|
457
|
+
)
|
|
320
458
|
}
|
|
321
459
|
|
|
322
|
-
const ctx = rlsContext.getContextOrNull()
|
|
460
|
+
const ctx = rlsContext.getContextOrNull()
|
|
323
461
|
|
|
324
|
-
if (
|
|
325
|
-
|
|
462
|
+
if (
|
|
463
|
+
ctx &&
|
|
464
|
+
!ctx.auth.isSystem &&
|
|
465
|
+
!bypassRoles.some(role => ctx.auth.roles.includes(role))
|
|
466
|
+
) {
|
|
326
467
|
// Fetch existing row for policy evaluation
|
|
327
468
|
// Use raw db if available to bypass RLS filtering and prevent self-interception
|
|
328
|
-
let existingRow: unknown
|
|
469
|
+
let existingRow: unknown
|
|
329
470
|
|
|
330
|
-
if (
|
|
471
|
+
if (hasRawDbInstance) {
|
|
331
472
|
// Use raw db to bypass RLS filtering
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
.selectAll()
|
|
335
|
-
.where('id' as any, '=', id as any)
|
|
336
|
-
.executeTakeFirst();
|
|
473
|
+
const query = selectFromDynamicTable(rawDb, table)
|
|
474
|
+
existingRow = await whereIdEquals(query, id, primaryKeyColumn).executeTakeFirst()
|
|
337
475
|
} else if (originalFindById) {
|
|
338
476
|
// Fallback to originalFindById for tests/mocks
|
|
339
|
-
existingRow = await originalFindById(id)
|
|
477
|
+
existingRow = await originalFindById(id)
|
|
340
478
|
} else {
|
|
341
|
-
throw new RLSError(
|
|
479
|
+
throw new RLSError(
|
|
480
|
+
'Repository does not support update operation',
|
|
481
|
+
RLSErrorCodes.RLS_POLICY_INVALID
|
|
482
|
+
)
|
|
342
483
|
}
|
|
343
484
|
|
|
344
485
|
if (!existingRow) {
|
|
345
486
|
// Let the original method handle not found
|
|
346
|
-
return originalUpdate(id, data)
|
|
487
|
+
return await originalUpdate(id, data)
|
|
347
488
|
}
|
|
348
489
|
|
|
349
490
|
try {
|
|
@@ -351,28 +492,28 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
351
492
|
table,
|
|
352
493
|
existingRow as Record<string, unknown>,
|
|
353
494
|
data as Record<string, unknown>
|
|
354
|
-
)
|
|
495
|
+
)
|
|
355
496
|
|
|
356
497
|
if (auditDecisions) {
|
|
357
|
-
logger.info?.('[RLS] Update allowed', { table, id, userId: ctx.auth.userId })
|
|
498
|
+
logger.info?.('[RLS] Update allowed', { table, id, userId: ctx.auth.userId })
|
|
358
499
|
}
|
|
359
500
|
} catch (error) {
|
|
360
501
|
if (error instanceof RLSPolicyViolation) {
|
|
361
|
-
onViolation?.(error)
|
|
502
|
+
onViolation?.(error)
|
|
362
503
|
if (auditDecisions) {
|
|
363
504
|
logger.warn?.('[RLS] Update denied', {
|
|
364
505
|
table,
|
|
365
506
|
id,
|
|
366
507
|
userId: ctx.auth.userId,
|
|
367
508
|
reason: error.reason
|
|
368
|
-
})
|
|
509
|
+
})
|
|
369
510
|
}
|
|
370
511
|
}
|
|
371
|
-
throw error
|
|
512
|
+
throw error
|
|
372
513
|
}
|
|
373
514
|
}
|
|
374
515
|
|
|
375
|
-
return originalUpdate(id, data)
|
|
516
|
+
return await originalUpdate(id, data)
|
|
376
517
|
},
|
|
377
518
|
|
|
378
519
|
/**
|
|
@@ -380,59 +521,65 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
380
521
|
*/
|
|
381
522
|
async delete(id: unknown): Promise<unknown> {
|
|
382
523
|
if (!originalDelete) {
|
|
383
|
-
throw new RLSError(
|
|
524
|
+
throw new RLSError(
|
|
525
|
+
'Repository does not support delete operation',
|
|
526
|
+
RLSErrorCodes.RLS_POLICY_INVALID
|
|
527
|
+
)
|
|
384
528
|
}
|
|
385
529
|
|
|
386
|
-
const ctx = rlsContext.getContextOrNull()
|
|
530
|
+
const ctx = rlsContext.getContextOrNull()
|
|
387
531
|
|
|
388
|
-
if (
|
|
389
|
-
|
|
532
|
+
if (
|
|
533
|
+
ctx &&
|
|
534
|
+
!ctx.auth.isSystem &&
|
|
535
|
+
!bypassRoles.some(role => ctx.auth.roles.includes(role))
|
|
536
|
+
) {
|
|
390
537
|
// Fetch existing row for policy evaluation
|
|
391
538
|
// Use raw db if available to bypass RLS filtering and prevent self-interception
|
|
392
|
-
let existingRow: unknown
|
|
539
|
+
let existingRow: unknown
|
|
393
540
|
|
|
394
|
-
if (
|
|
541
|
+
if (hasRawDbInstance) {
|
|
395
542
|
// Use raw db to bypass RLS filtering
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.selectAll()
|
|
399
|
-
.where('id' as any, '=', id as any)
|
|
400
|
-
.executeTakeFirst();
|
|
543
|
+
const query = selectFromDynamicTable(rawDb, table)
|
|
544
|
+
existingRow = await whereIdEquals(query, id, primaryKeyColumn).executeTakeFirst()
|
|
401
545
|
} else if (originalFindById) {
|
|
402
546
|
// Fallback to originalFindById for tests/mocks
|
|
403
|
-
existingRow = await originalFindById(id)
|
|
547
|
+
existingRow = await originalFindById(id)
|
|
404
548
|
} else {
|
|
405
|
-
throw new RLSError(
|
|
549
|
+
throw new RLSError(
|
|
550
|
+
'Repository does not support delete operation',
|
|
551
|
+
RLSErrorCodes.RLS_POLICY_INVALID
|
|
552
|
+
)
|
|
406
553
|
}
|
|
407
554
|
|
|
408
555
|
if (!existingRow) {
|
|
409
556
|
// Let the original method handle not found
|
|
410
|
-
return originalDelete(id)
|
|
557
|
+
return await originalDelete(id)
|
|
411
558
|
}
|
|
412
559
|
|
|
413
560
|
try {
|
|
414
|
-
await mutationGuard.checkDelete(table, existingRow as Record<string, unknown>)
|
|
561
|
+
await mutationGuard.checkDelete(table, existingRow as Record<string, unknown>)
|
|
415
562
|
|
|
416
563
|
if (auditDecisions) {
|
|
417
|
-
logger.info?.('[RLS] Delete allowed', { table, id, userId: ctx.auth.userId })
|
|
564
|
+
logger.info?.('[RLS] Delete allowed', { table, id, userId: ctx.auth.userId })
|
|
418
565
|
}
|
|
419
566
|
} catch (error) {
|
|
420
567
|
if (error instanceof RLSPolicyViolation) {
|
|
421
|
-
onViolation?.(error)
|
|
568
|
+
onViolation?.(error)
|
|
422
569
|
if (auditDecisions) {
|
|
423
570
|
logger.warn?.('[RLS] Delete denied', {
|
|
424
571
|
table,
|
|
425
572
|
id,
|
|
426
573
|
userId: ctx.auth.userId,
|
|
427
574
|
reason: error.reason
|
|
428
|
-
})
|
|
575
|
+
})
|
|
429
576
|
}
|
|
430
577
|
}
|
|
431
|
-
throw error
|
|
578
|
+
throw error
|
|
432
579
|
}
|
|
433
580
|
}
|
|
434
581
|
|
|
435
|
-
return originalDelete(id)
|
|
582
|
+
return await originalDelete(id)
|
|
436
583
|
},
|
|
437
584
|
|
|
438
585
|
/**
|
|
@@ -448,7 +595,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
448
595
|
* ```
|
|
449
596
|
*/
|
|
450
597
|
async withoutRLS<R>(fn: () => Promise<R>): Promise<R> {
|
|
451
|
-
return rlsContext.asSystemAsync(fn)
|
|
598
|
+
return await rlsContext.asSystemAsync(fn)
|
|
452
599
|
},
|
|
453
600
|
|
|
454
601
|
/**
|
|
@@ -464,39 +611,39 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
|
|
|
464
611
|
* ```
|
|
465
612
|
*/
|
|
466
613
|
async canAccess(operation: Operation, row: Record<string, unknown>): Promise<boolean> {
|
|
467
|
-
const ctx = rlsContext.getContextOrNull()
|
|
468
|
-
if (!ctx) return false
|
|
469
|
-
if (ctx.auth.isSystem) return true
|
|
470
|
-
if (bypassRoles.some(role => ctx.auth.roles.includes(role))) return true
|
|
614
|
+
const ctx = rlsContext.getContextOrNull()
|
|
615
|
+
if (!ctx) return false
|
|
616
|
+
if (ctx.auth.isSystem) return true
|
|
617
|
+
if (bypassRoles.some(role => ctx.auth.roles.includes(role))) return true
|
|
471
618
|
|
|
472
619
|
try {
|
|
473
620
|
switch (operation) {
|
|
474
621
|
case 'read':
|
|
475
|
-
return await mutationGuard.checkRead(table, row)
|
|
622
|
+
return await mutationGuard.checkRead(table, row)
|
|
476
623
|
case 'create':
|
|
477
|
-
await mutationGuard.checkCreate(table, row)
|
|
478
|
-
return true
|
|
624
|
+
await mutationGuard.checkCreate(table, row)
|
|
625
|
+
return true
|
|
479
626
|
case 'update':
|
|
480
|
-
await mutationGuard.checkUpdate(table, row, {})
|
|
481
|
-
return true
|
|
627
|
+
await mutationGuard.checkUpdate(table, row, {})
|
|
628
|
+
return true
|
|
482
629
|
case 'delete':
|
|
483
|
-
await mutationGuard.checkDelete(table, row)
|
|
484
|
-
return true
|
|
630
|
+
await mutationGuard.checkDelete(table, row)
|
|
631
|
+
return true
|
|
485
632
|
default:
|
|
486
|
-
return false
|
|
633
|
+
return false
|
|
487
634
|
}
|
|
488
635
|
} catch (error) {
|
|
489
636
|
logger.debug?.('[RLS] Access check failed', {
|
|
490
637
|
table,
|
|
491
638
|
operation,
|
|
492
639
|
error: error instanceof Error ? error.message : String(error)
|
|
493
|
-
})
|
|
494
|
-
return false
|
|
640
|
+
})
|
|
641
|
+
return false
|
|
495
642
|
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
498
645
|
|
|
499
|
-
return extendedRepo as T
|
|
500
|
-
}
|
|
501
|
-
}
|
|
646
|
+
return extendedRepo as T
|
|
647
|
+
}
|
|
648
|
+
}
|
|
502
649
|
}
|