@kysera/rls 0.8.1 → 0.8.3

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,442 @@
1
+ /**
2
+ * Field Access Processor
3
+ *
4
+ * Applies field-level access control to database rows and mutation data.
5
+ *
6
+ * @module @kysera/rls/field-access/processor
7
+ */
8
+
9
+ import type { FieldAccessRegistry } from './registry.js'
10
+ import type {
11
+ MaskedRow,
12
+ FieldAccessOptions,
13
+ FieldAccessResult,
14
+ CompiledFieldAccess
15
+ } from './types.js'
16
+ import type { PolicyEvaluationContext, RLSContext } from '../policy/types.js'
17
+ import { rlsContext } from '../context/manager.js'
18
+ import { RLSPolicyViolation } from '../errors.js'
19
+
20
+ // ============================================================================
21
+ // Field Access Processor
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Field Access Processor
26
+ *
27
+ * Applies field-level access control rules to rows and mutation data.
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const processor = new FieldAccessProcessor(registry);
32
+ *
33
+ * // Mask fields in a row
34
+ * const result = await processor.maskRow('users', user, {
35
+ * includeMetadata: true
36
+ * });
37
+ *
38
+ * console.log(result.data); // Row with masked fields
39
+ * console.log(result.maskedFields); // ['email', 'phone']
40
+ * console.log(result.omittedFields); // ['mfa_secret']
41
+ *
42
+ * // Validate write access
43
+ * await processor.validateWrite('users', { email: 'new@example.com' });
44
+ * ```
45
+ */
46
+ export class FieldAccessProcessor<DB = unknown> {
47
+ constructor(
48
+ private registry: FieldAccessRegistry<DB>,
49
+ private defaultMaskValue: unknown = null
50
+ ) {}
51
+
52
+ /**
53
+ * Apply field access control to a single row
54
+ *
55
+ * @param table - Table name
56
+ * @param row - Row data
57
+ * @param options - Processing options
58
+ * @returns Masked row with metadata
59
+ */
60
+ async maskRow<T extends Record<string, unknown>>(
61
+ table: string,
62
+ row: T,
63
+ options: FieldAccessOptions = {}
64
+ ): Promise<MaskedRow<T>> {
65
+ const ctx = this.getContext()
66
+ if (!ctx) {
67
+ // No context - return original row
68
+ return {
69
+ data: row,
70
+ maskedFields: [],
71
+ omittedFields: []
72
+ }
73
+ }
74
+
75
+ // System user sees everything
76
+ if (ctx.auth.isSystem) {
77
+ return {
78
+ data: row,
79
+ maskedFields: [],
80
+ omittedFields: []
81
+ }
82
+ }
83
+
84
+ const tableConfig = this.registry.getTableConfig(table)
85
+ if (!tableConfig) {
86
+ // No field access config - return original
87
+ return {
88
+ data: row,
89
+ maskedFields: [],
90
+ omittedFields: []
91
+ }
92
+ }
93
+
94
+ // Check skipFor roles
95
+ if (tableConfig.skipFor.some(role => ctx.auth.roles.includes(role))) {
96
+ return {
97
+ data: row,
98
+ maskedFields: [],
99
+ omittedFields: []
100
+ }
101
+ }
102
+
103
+ const evalCtx = this.createEvalContext(ctx, row, table)
104
+ const result: Partial<T> = {}
105
+ const maskedFields: string[] = []
106
+ const omittedFields: string[] = []
107
+
108
+ // Process each field
109
+ for (const [field, value] of Object.entries(row)) {
110
+ // Check explicit include/exclude
111
+ if (options.excludeFields?.includes(field)) {
112
+ continue
113
+ }
114
+ if (options.includeFields && !options.includeFields.includes(field)) {
115
+ continue
116
+ }
117
+
118
+ const fieldResult = await this.evaluateFieldAccess(
119
+ tableConfig,
120
+ field,
121
+ value,
122
+ evalCtx,
123
+ options
124
+ )
125
+
126
+ if (fieldResult.omit) {
127
+ omittedFields.push(field)
128
+ } else if (!fieldResult.accessible) {
129
+ maskedFields.push(field)
130
+ ;(result as Record<string, unknown>)[field] = fieldResult.value
131
+ } else {
132
+ ;(result as Record<string, unknown>)[field] = value
133
+ }
134
+ }
135
+
136
+ return {
137
+ data: result,
138
+ maskedFields,
139
+ omittedFields
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Apply field access control to multiple rows
145
+ *
146
+ * @param table - Table name
147
+ * @param rows - Array of rows
148
+ * @param options - Processing options
149
+ * @returns Array of masked rows
150
+ */
151
+ async maskRows<T extends Record<string, unknown>>(
152
+ table: string,
153
+ rows: T[],
154
+ options: FieldAccessOptions = {}
155
+ ): Promise<MaskedRow<T>[]> {
156
+ return await Promise.all(rows.map(row => this.maskRow(table, row, options)))
157
+ }
158
+
159
+ /**
160
+ * Validate that all fields in mutation data are writable
161
+ *
162
+ * @param table - Table name
163
+ * @param data - Mutation data
164
+ * @param existingRow - Existing row (for update operations)
165
+ * @throws RLSPolicyViolation if any field is not writable
166
+ */
167
+ async validateWrite(
168
+ table: string,
169
+ data: Record<string, unknown>,
170
+ existingRow?: Record<string, unknown>
171
+ ): Promise<void> {
172
+ const ctx = this.getContext()
173
+ if (!ctx) {
174
+ return // No context = no validation
175
+ }
176
+
177
+ if (ctx.auth.isSystem) {
178
+ return // System user can write anything
179
+ }
180
+
181
+ const tableConfig = this.registry.getTableConfig(table)
182
+ if (!tableConfig) {
183
+ return // No field access config
184
+ }
185
+
186
+ // Check skipFor roles
187
+ if (tableConfig.skipFor.some(role => ctx.auth.roles.includes(role))) {
188
+ return
189
+ }
190
+
191
+ const evalCtx = this.createEvalContext(ctx, existingRow ?? {}, table, data)
192
+
193
+ // Check each field being written
194
+ const unwritableFields: string[] = []
195
+
196
+ for (const field of Object.keys(data)) {
197
+ const canWrite = await this.registry.canWriteField(table, field, evalCtx)
198
+ if (!canWrite) {
199
+ unwritableFields.push(field)
200
+ }
201
+ }
202
+
203
+ if (unwritableFields.length > 0) {
204
+ throw new RLSPolicyViolation(
205
+ 'write',
206
+ table,
207
+ `Cannot write to protected fields: ${unwritableFields.join(', ')}`
208
+ )
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Filter mutation data to only include writable fields
214
+ *
215
+ * @param table - Table name
216
+ * @param data - Mutation data
217
+ * @param existingRow - Existing row (for update operations)
218
+ * @returns Filtered data with only writable fields
219
+ */
220
+ async filterWritableFields(
221
+ table: string,
222
+ data: Record<string, unknown>,
223
+ existingRow?: Record<string, unknown>
224
+ ): Promise<{ data: Record<string, unknown>; removedFields: string[] }> {
225
+ const ctx = this.getContext()
226
+ if (!ctx) {
227
+ return { data, removedFields: [] }
228
+ }
229
+
230
+ if (ctx.auth.isSystem) {
231
+ return { data, removedFields: [] }
232
+ }
233
+
234
+ const tableConfig = this.registry.getTableConfig(table)
235
+ if (!tableConfig) {
236
+ return { data, removedFields: [] }
237
+ }
238
+
239
+ // Check skipFor roles
240
+ if (tableConfig.skipFor.some(role => ctx.auth.roles.includes(role))) {
241
+ return { data, removedFields: [] }
242
+ }
243
+
244
+ const evalCtx = this.createEvalContext(ctx, existingRow ?? {}, table, data)
245
+ const result: Record<string, unknown> = {}
246
+ const removedFields: string[] = []
247
+
248
+ for (const [field, value] of Object.entries(data)) {
249
+ const canWrite = await this.registry.canWriteField(table, field, evalCtx)
250
+ if (canWrite) {
251
+ result[field] = value
252
+ } else {
253
+ removedFields.push(field)
254
+ }
255
+ }
256
+
257
+ return { data: result, removedFields }
258
+ }
259
+
260
+ /**
261
+ * Get list of readable fields for a table
262
+ *
263
+ * @param table - Table name
264
+ * @param row - Row data (for context-dependent fields)
265
+ * @returns Array of readable field names
266
+ */
267
+ async getReadableFields(table: string, row: Record<string, unknown>): Promise<string[]> {
268
+ const ctx = this.getContext()
269
+ if (!ctx || ctx.auth.isSystem) {
270
+ return Object.keys(row)
271
+ }
272
+
273
+ const tableConfig = this.registry.getTableConfig(table)
274
+ if (!tableConfig) {
275
+ return Object.keys(row)
276
+ }
277
+
278
+ // Check skipFor roles
279
+ if (tableConfig.skipFor.some(role => ctx.auth.roles.includes(role))) {
280
+ return Object.keys(row)
281
+ }
282
+
283
+ const evalCtx = this.createEvalContext(ctx, row, table)
284
+ const readable: string[] = []
285
+
286
+ for (const field of Object.keys(row)) {
287
+ const canRead = await this.registry.canReadField(table, field, evalCtx)
288
+ if (canRead) {
289
+ readable.push(field)
290
+ }
291
+ }
292
+
293
+ return readable
294
+ }
295
+
296
+ /**
297
+ * Get list of writable fields for a table
298
+ *
299
+ * @param table - Table name
300
+ * @param row - Existing row data (for context-dependent fields)
301
+ * @returns Array of writable field names
302
+ */
303
+ async getWritableFields(table: string, row: Record<string, unknown>): Promise<string[]> {
304
+ const ctx = this.getContext()
305
+ if (!ctx || ctx.auth.isSystem) {
306
+ return Object.keys(row)
307
+ }
308
+
309
+ const tableConfig = this.registry.getTableConfig(table)
310
+ if (!tableConfig) {
311
+ return Object.keys(row)
312
+ }
313
+
314
+ // Check skipFor roles
315
+ if (tableConfig.skipFor.some(role => ctx.auth.roles.includes(role))) {
316
+ return Object.keys(row)
317
+ }
318
+
319
+ const evalCtx = this.createEvalContext(ctx, row, table)
320
+ const writable: string[] = []
321
+
322
+ for (const field of Object.keys(row)) {
323
+ const canWrite = await this.registry.canWriteField(table, field, evalCtx)
324
+ if (canWrite) {
325
+ writable.push(field)
326
+ }
327
+ }
328
+
329
+ return writable
330
+ }
331
+
332
+ // ============================================================================
333
+ // Private Methods
334
+ // ============================================================================
335
+
336
+ /**
337
+ * Get current RLS context
338
+ */
339
+ private getContext(): RLSContext | null {
340
+ return rlsContext.getContextOrNull()
341
+ }
342
+
343
+ /**
344
+ * Create evaluation context
345
+ */
346
+ private createEvalContext(
347
+ ctx: RLSContext,
348
+ row: Record<string, unknown>,
349
+ table: string,
350
+ data?: Record<string, unknown>
351
+ ): PolicyEvaluationContext {
352
+ return {
353
+ auth: ctx.auth,
354
+ row,
355
+ data,
356
+ table,
357
+ ...(ctx.meta !== undefined && { meta: ctx.meta as Record<string, unknown> })
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Evaluate field access for a specific field
363
+ */
364
+ private async evaluateFieldAccess(
365
+ tableConfig: {
366
+ defaultAccess: 'allow' | 'deny'
367
+ fields: Map<string, CompiledFieldAccess>
368
+ },
369
+ field: string,
370
+ value: unknown,
371
+ ctx: PolicyEvaluationContext,
372
+ options: FieldAccessOptions
373
+ ): Promise<FieldAccessResult> {
374
+ const fieldConfig = tableConfig.fields.get(field)
375
+
376
+ if (!fieldConfig) {
377
+ // Use default access policy
378
+ const accessible = tableConfig.defaultAccess === 'allow'
379
+ return {
380
+ accessible,
381
+ value: accessible ? value : this.defaultMaskValue,
382
+ omit: !accessible && options.throwOnDenied !== true
383
+ }
384
+ }
385
+
386
+ try {
387
+ const canRead = await fieldConfig.canRead(ctx)
388
+
389
+ if (canRead) {
390
+ return {
391
+ accessible: true,
392
+ value
393
+ } as FieldAccessResult
394
+ }
395
+
396
+ if (options.throwOnDenied) {
397
+ throw new RLSPolicyViolation('read', ctx.table ?? 'unknown', `Cannot read field: ${field}`)
398
+ }
399
+
400
+ // Check if there's a mask function
401
+ const configWithMask = fieldConfig as CompiledFieldAccess & {
402
+ maskFn?: (value: unknown) => unknown
403
+ }
404
+ const maskedValue = configWithMask.maskFn
405
+ ? configWithMask.maskFn(value)
406
+ : fieldConfig.maskedValue ?? this.defaultMaskValue
407
+
408
+ return {
409
+ accessible: false,
410
+ reason: `Field "${field}" is not accessible`,
411
+ value: maskedValue,
412
+ omit: fieldConfig.omitWhenHidden
413
+ }
414
+ } catch (error) {
415
+ if (error instanceof RLSPolicyViolation) {
416
+ throw error
417
+ }
418
+
419
+ // Log error and fail closed
420
+ return {
421
+ accessible: false,
422
+ reason: `Error evaluating access: ${error instanceof Error ? error.message : String(error)}`,
423
+ value: this.defaultMaskValue,
424
+ omit: true
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ // ============================================================================
431
+ // Factory Function
432
+ // ============================================================================
433
+
434
+ /**
435
+ * Create a field access processor
436
+ */
437
+ export function createFieldAccessProcessor<DB = unknown>(
438
+ registry: FieldAccessRegistry<DB>,
439
+ defaultMaskValue?: unknown
440
+ ): FieldAccessProcessor<DB> {
441
+ return new FieldAccessProcessor<DB>(registry, defaultMaskValue)
442
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Field Access Registry
3
+ *
4
+ * Manages field-level access control configurations across tables.
5
+ *
6
+ * @module @kysera/rls/field-access/registry
7
+ */
8
+
9
+ import type {
10
+ FieldAccessSchema,
11
+ TableFieldAccessConfig,
12
+ FieldAccessConfig,
13
+ CompiledTableFieldAccess,
14
+ CompiledFieldAccess
15
+ } from './types.js'
16
+ import type { PolicyEvaluationContext } from '../policy/types.js'
17
+ import { silentLogger, type KyseraLogger } from '@kysera/core'
18
+
19
+ // ============================================================================
20
+ // Field Access Registry
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Field Access Registry
25
+ *
26
+ * Manages field-level access control configurations for all tables.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const registry = new FieldAccessRegistry();
31
+ *
32
+ * registry.loadSchema<Database>({
33
+ * users: {
34
+ * default: 'allow',
35
+ * fields: {
36
+ * email: ownerOrRoles(['admin'], 'id'),
37
+ * password_hash: neverAccessible(),
38
+ * mfa_secret: ownerOnly('id')
39
+ * }
40
+ * }
41
+ * });
42
+ *
43
+ * // Check if field is accessible
44
+ * const canRead = await registry.canReadField('users', 'email', evalCtx);
45
+ * ```
46
+ */
47
+ export class FieldAccessRegistry<DB = unknown> {
48
+ private tables = new Map<string, CompiledTableFieldAccess>()
49
+ private logger: KyseraLogger
50
+
51
+ constructor(schema?: FieldAccessSchema<DB>, options?: { logger?: KyseraLogger }) {
52
+ this.logger = options?.logger ?? silentLogger
53
+ if (schema) {
54
+ this.loadSchema(schema)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Load field access schema
60
+ */
61
+ loadSchema(schema: FieldAccessSchema<DB>): void {
62
+ for (const [table, config] of Object.entries(schema)) {
63
+ if (!config) continue
64
+ this.registerTable(table, config as TableFieldAccessConfig)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Register field access configuration for a table
70
+ */
71
+ registerTable(table: string, config: TableFieldAccessConfig): void {
72
+ const compiled: CompiledTableFieldAccess = {
73
+ table,
74
+ defaultAccess: config.default ?? 'allow',
75
+ skipFor: config.skipFor ?? [],
76
+ fields: new Map()
77
+ }
78
+
79
+ // Compile field configurations
80
+ for (const [field, fieldConfig] of Object.entries(config.fields)) {
81
+ if (!fieldConfig) continue
82
+
83
+ const compiledField = this.compileFieldConfig(field, fieldConfig as FieldAccessConfig)
84
+ compiled.fields.set(field, compiledField)
85
+ }
86
+
87
+ this.tables.set(table, compiled)
88
+ this.logger.info?.(`[FieldAccess] Registered table: ${table}`, {
89
+ fields: compiled.fields.size,
90
+ defaultAccess: compiled.defaultAccess
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Check if a field is readable in the current context
96
+ *
97
+ * @param table - Table name
98
+ * @param field - Field name
99
+ * @param ctx - Evaluation context
100
+ * @returns True if field is readable
101
+ */
102
+ async canReadField(table: string, field: string, ctx: PolicyEvaluationContext): Promise<boolean> {
103
+ const config = this.tables.get(table)
104
+ if (!config) {
105
+ // No field access config = all fields readable
106
+ return true
107
+ }
108
+
109
+ // Check skipFor roles
110
+ if (config.skipFor.some(role => ctx.auth.roles.includes(role))) {
111
+ return true
112
+ }
113
+
114
+ // System user bypasses field access
115
+ if (ctx.auth.isSystem) {
116
+ return true
117
+ }
118
+
119
+ const fieldConfig = config.fields.get(field)
120
+ if (!fieldConfig) {
121
+ // Use default policy
122
+ return config.defaultAccess === 'allow'
123
+ }
124
+
125
+ try {
126
+ const result = fieldConfig.canRead(ctx)
127
+ return result instanceof Promise ? await result : result
128
+ } catch (error) {
129
+ this.logger.error?.(`[FieldAccess] Error checking read access for ${table}.${field}`, {
130
+ error: error instanceof Error ? error.message : String(error)
131
+ })
132
+ return false // Fail closed
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Check if a field is writable in the current context
138
+ *
139
+ * @param table - Table name
140
+ * @param field - Field name
141
+ * @param ctx - Evaluation context
142
+ * @returns True if field is writable
143
+ */
144
+ async canWriteField(table: string, field: string, ctx: PolicyEvaluationContext): Promise<boolean> {
145
+ const config = this.tables.get(table)
146
+ if (!config) {
147
+ return true
148
+ }
149
+
150
+ // Check skipFor roles
151
+ if (config.skipFor.some(role => ctx.auth.roles.includes(role))) {
152
+ return true
153
+ }
154
+
155
+ // System user bypasses field access
156
+ if (ctx.auth.isSystem) {
157
+ return true
158
+ }
159
+
160
+ const fieldConfig = config.fields.get(field)
161
+ if (!fieldConfig) {
162
+ return config.defaultAccess === 'allow'
163
+ }
164
+
165
+ try {
166
+ const result = fieldConfig.canWrite(ctx)
167
+ return result instanceof Promise ? await result : result
168
+ } catch (error) {
169
+ this.logger.error?.(`[FieldAccess] Error checking write access for ${table}.${field}`, {
170
+ error: error instanceof Error ? error.message : String(error)
171
+ })
172
+ return false
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Get field configuration
178
+ *
179
+ * @param table - Table name
180
+ * @param field - Field name
181
+ * @returns Compiled field access config or undefined
182
+ */
183
+ getFieldConfig(table: string, field: string): CompiledFieldAccess | undefined {
184
+ return this.tables.get(table)?.fields.get(field)
185
+ }
186
+
187
+ /**
188
+ * Get table configuration
189
+ *
190
+ * @param table - Table name
191
+ * @returns Compiled table field access config or undefined
192
+ */
193
+ getTableConfig(table: string): CompiledTableFieldAccess | undefined {
194
+ return this.tables.get(table)
195
+ }
196
+
197
+ /**
198
+ * Check if table has field access configuration
199
+ */
200
+ hasTable(table: string): boolean {
201
+ return this.tables.has(table)
202
+ }
203
+
204
+ /**
205
+ * Get all registered table names
206
+ */
207
+ getTables(): string[] {
208
+ return Array.from(this.tables.keys())
209
+ }
210
+
211
+ /**
212
+ * Get all fields with explicit configuration for a table
213
+ *
214
+ * @param table - Table name
215
+ * @returns Array of field names
216
+ */
217
+ getConfiguredFields(table: string): string[] {
218
+ const config = this.tables.get(table)
219
+ return config ? Array.from(config.fields.keys()) : []
220
+ }
221
+
222
+ /**
223
+ * Clear all configurations
224
+ */
225
+ clear(): void {
226
+ this.tables.clear()
227
+ }
228
+
229
+ // ============================================================================
230
+ // Private Methods
231
+ // ============================================================================
232
+
233
+ /**
234
+ * Compile a field access configuration
235
+ */
236
+ private compileFieldConfig(field: string, config: FieldAccessConfig): CompiledFieldAccess {
237
+ return {
238
+ field,
239
+ canRead: config.read ?? (() => true),
240
+ canWrite: config.write ?? (() => true),
241
+ maskedValue: config.maskedValue ?? null,
242
+ omitWhenHidden: config.omitWhenHidden ?? false
243
+ }
244
+ }
245
+ }
246
+
247
+ // ============================================================================
248
+ // Factory Function
249
+ // ============================================================================
250
+
251
+ /**
252
+ * Create a field access registry
253
+ */
254
+ export function createFieldAccessRegistry<DB = unknown>(
255
+ schema?: FieldAccessSchema<DB>,
256
+ options?: { logger?: KyseraLogger }
257
+ ): FieldAccessRegistry<DB> {
258
+ return new FieldAccessRegistry<DB>(schema, options)
259
+ }