@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.
- package/README.md +819 -3
- package/dist/index.d.ts +2841 -11
- package/dist/index.js +2451 -1
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/{types-Dowjd6zG.d.ts → types-CyqksFKU.d.ts} +72 -1
- package/package.json +11 -6
- package/src/audit/index.ts +25 -0
- package/src/audit/logger.ts +465 -0
- package/src/audit/types.ts +625 -0
- package/src/composition/builder.ts +556 -0
- package/src/composition/index.ts +43 -0
- package/src/composition/types.ts +415 -0
- package/src/field-access/index.ts +38 -0
- package/src/field-access/processor.ts +442 -0
- package/src/field-access/registry.ts +259 -0
- package/src/field-access/types.ts +453 -0
- package/src/index.ts +180 -2
- package/src/policy/builder.ts +187 -10
- package/src/policy/types.ts +84 -0
- package/src/rebac/index.ts +30 -0
- package/src/rebac/registry.ts +303 -0
- package/src/rebac/transformer.ts +391 -0
- package/src/rebac/types.ts +412 -0
- package/src/resolvers/index.ts +30 -0
- package/src/resolvers/manager.ts +507 -0
- package/src/resolvers/types.ts +447 -0
- package/src/testing/index.ts +554 -0
|
@@ -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
|
+
}
|