@api-client/core 0.20.1 → 0.20.2
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/build/src/modeling/RuntimeApiModel.d.ts +14 -18
- package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
- package/build/src/modeling/RuntimeApiModel.js +14 -52
- package/build/src/modeling/RuntimeApiModel.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/modeling/RuntimeApiModel.ts +19 -61
- package/tests/unit/modeling/RuntimeApiModel.spec.ts +40 -108
package/package.json
CHANGED
|
@@ -39,19 +39,20 @@ export interface RuntimeApiModelSchema extends ApiModelSchema {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface RuntimeResolvedAction {
|
|
42
|
-
|
|
42
|
+
exposure: ExposedEntity
|
|
43
|
+
entity: DomainEntity
|
|
43
44
|
action: Action
|
|
44
45
|
params: Record<string, string>
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export type RuleEvaluator = (rule: AccessRule) => Promise<boolean | undefined> | boolean | undefined
|
|
48
49
|
|
|
49
|
-
interface PhaseRules {
|
|
50
|
+
export interface PhaseRules {
|
|
50
51
|
permissionRules: AccessRule[]
|
|
51
52
|
mandatoryRules: AccessRule[]
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
interface ActionRulesCache {
|
|
55
|
+
export interface ActionRulesCache {
|
|
55
56
|
preFetch: PhaseRules
|
|
56
57
|
fetch: PhaseRules
|
|
57
58
|
postFetch: PhaseRules
|
|
@@ -281,17 +282,23 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
281
282
|
|
|
282
283
|
const params = exec(path, matchedRoute)
|
|
283
284
|
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
286
|
-
|
|
285
|
+
const exposure = this.exposes.get(def.lookup.exposedEntityKey)
|
|
286
|
+
if (!exposure) {
|
|
287
|
+
throw new Exception('Missing exposure ' + def.lookup.exposedEntityKey, { code: 'API_MODEL_ERROR', status: 500 })
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
const action =
|
|
290
|
+
const action = exposure.actions.find((a) => a.kind === def.lookup.actionKind)
|
|
290
291
|
if (!action) {
|
|
291
|
-
|
|
292
|
+
throw new Exception('Missing action ' + def.lookup.actionKind, { code: 'API_MODEL_ERROR', status: 500 })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const entity = this.domain?.findEntity(exposure.entity.key, exposure.entity.domain)
|
|
296
|
+
if (!entity) {
|
|
297
|
+
throw new Exception('Missing entity ' + exposure.entity.key, { code: 'API_MODEL_ERROR', status: 500 })
|
|
292
298
|
}
|
|
293
299
|
|
|
294
300
|
return {
|
|
301
|
+
exposure,
|
|
295
302
|
entity,
|
|
296
303
|
action,
|
|
297
304
|
params,
|
|
@@ -299,64 +306,15 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* The evaluation process follows two phases per execution phase (PRE_FETCH, FETCH, POST_FETCH):
|
|
305
|
-
* 1. Mandatory Phase: All rules marked as `mandatory: true` across all levels must return true.
|
|
306
|
-
* If any fail, the request is immediately rejected.
|
|
307
|
-
* 2. Permission Phase: Evaluation follows the hierarchy from most specific to most general:
|
|
308
|
-
* Action -> Endpoint (ExposedEntity) -> API Model.
|
|
309
|
-
* If an explicit allow (true) or deny (false) is hit, evaluation stops and returns the result.
|
|
310
|
-
* If no rules match (all return undefined), the request is rejected by default.
|
|
311
|
-
*
|
|
312
|
-
* @param action The resolved action to evaluate.
|
|
313
|
-
* @param evaluator A callback that evaluates a single rule. Should return true (allow),
|
|
314
|
-
* false (deny), or undefined (no hit).
|
|
315
|
-
* @param phase The execution phase to evaluate.
|
|
316
|
-
* @returns A promise that resolves to true if access is granted, false if denied.
|
|
309
|
+
* Retrieves the precomputed and shadowed effective rules for a given action.
|
|
317
310
|
*/
|
|
318
|
-
|
|
319
|
-
action: RuntimeResolvedAction,
|
|
320
|
-
evaluator: RuleEvaluator,
|
|
321
|
-
phase: AccessRuleExecutionPhase
|
|
322
|
-
): Promise<boolean> {
|
|
311
|
+
getEffectiveRules(action: RuntimeResolvedAction): ActionRulesCache {
|
|
323
312
|
let cachedRules = this.#actionRulesCache.get(action.action)
|
|
324
313
|
if (!cachedRules) {
|
|
325
|
-
|
|
326
|
-
cachedRules = this.#computeEffectiveRules(action.action, action.entity)
|
|
314
|
+
cachedRules = this.#computeEffectiveRules(action.action, action.exposure)
|
|
327
315
|
this.#actionRulesCache.set(action.action, cachedRules)
|
|
328
316
|
}
|
|
329
|
-
|
|
330
|
-
let rulesForPhase
|
|
331
|
-
if (phase === AccessRuleExecutionPhase.POST_FETCH) {
|
|
332
|
-
rulesForPhase = cachedRules.postFetch
|
|
333
|
-
} else if (phase === AccessRuleExecutionPhase.FETCH) {
|
|
334
|
-
rulesForPhase = cachedRules.fetch
|
|
335
|
-
} else {
|
|
336
|
-
rulesForPhase = cachedRules.preFetch
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Step 1: Mandatory Phase
|
|
340
|
-
for (const rule of rulesForPhase.mandatoryRules) {
|
|
341
|
-
const result = await evaluator(rule)
|
|
342
|
-
if (result !== true) {
|
|
343
|
-
return false // Immediately reject if any mandatory rule fails
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Step 2-4: Permission Phase
|
|
348
|
-
for (const rule of rulesForPhase.permissionRules) {
|
|
349
|
-
const result = await evaluator(rule)
|
|
350
|
-
if (result === true) {
|
|
351
|
-
return true
|
|
352
|
-
}
|
|
353
|
-
if (result === false) {
|
|
354
|
-
return false
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Default: no hit
|
|
359
|
-
return false
|
|
317
|
+
return cachedRules
|
|
360
318
|
}
|
|
361
319
|
|
|
362
320
|
override toJSON(): RuntimeApiModelSchema {
|
|
@@ -10,11 +10,10 @@ test.group('RuntimeApiModel', () => {
|
|
|
10
10
|
test('initializes from schema', ({ assert }) => {
|
|
11
11
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
12
12
|
const model = domain.addModel({ info: { name: 'Test Model' } })
|
|
13
|
-
const entity = model.addEntity({
|
|
13
|
+
const entity = model.addEntity({ info: { name: 'User' } })
|
|
14
14
|
|
|
15
15
|
const baseModel = new ApiModel(
|
|
16
16
|
{
|
|
17
|
-
key: 'api-1',
|
|
18
17
|
info: { name: 'Test API' },
|
|
19
18
|
},
|
|
20
19
|
domain
|
|
@@ -37,13 +36,15 @@ test.group('RuntimeApiModel', () => {
|
|
|
37
36
|
|
|
38
37
|
test('resolves path with matchit', ({ assert }) => {
|
|
39
38
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
39
|
+
const model = domain.addModel({ info: { name: 'Test Model' } })
|
|
40
|
+
const entity = model.addEntity({ info: { name: 'User' } })
|
|
40
41
|
const schema = {
|
|
41
42
|
key: 'api-1',
|
|
42
43
|
info: { name: 'Test API' },
|
|
43
44
|
exposes: [
|
|
44
45
|
{
|
|
45
46
|
key: 'expose-1',
|
|
46
|
-
entity: { key:
|
|
47
|
+
entity: { key: entity.key, domain: domain.key },
|
|
47
48
|
actions: [
|
|
48
49
|
{ kind: 'read', type: 'crud' },
|
|
49
50
|
{ kind: 'list', type: 'crud' },
|
|
@@ -92,7 +93,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
92
93
|
assert.isUndefined(getResult)
|
|
93
94
|
}).tags(['@modeling', '@runtime'])
|
|
94
95
|
|
|
95
|
-
test('
|
|
96
|
+
test('throws error when entity or action is missing', ({ assert }) => {
|
|
96
97
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
97
98
|
const schema = {
|
|
98
99
|
key: 'api-1',
|
|
@@ -115,12 +116,14 @@ test.group('RuntimeApiModel', () => {
|
|
|
115
116
|
const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
|
|
116
117
|
|
|
117
118
|
// Exposed entity not found
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
assert.throws(() => {
|
|
120
|
+
runtimeModel.lookupAction('GET', '/missing-entity')
|
|
121
|
+
}, 'Missing exposure missing-expose')
|
|
120
122
|
|
|
121
123
|
// Action not found on entity
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
assert.throws(() => {
|
|
125
|
+
runtimeModel.lookupAction('GET', '/missing-action')
|
|
126
|
+
}, 'Missing action read')
|
|
124
127
|
}).tags(['@modeling', '@runtime'])
|
|
125
128
|
|
|
126
129
|
test('caches user entity and properties based on semantics', ({ assert }) => {
|
|
@@ -211,7 +214,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
211
214
|
assert.equal(runtimeModel.sessionProperties.size, 0)
|
|
212
215
|
}).tags(['@modeling', '@runtime'])
|
|
213
216
|
|
|
214
|
-
test('
|
|
217
|
+
test('getEffectiveRules - separates mandatory and permission rules', async ({ assert }) => {
|
|
215
218
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
216
219
|
const baseModel = new ApiModel(
|
|
217
220
|
{ key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
|
|
@@ -220,40 +223,41 @@ test.group('RuntimeApiModel', () => {
|
|
|
220
223
|
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
221
224
|
|
|
222
225
|
const mockAction = {
|
|
223
|
-
|
|
226
|
+
exposure: { accessRule: [], parent: undefined },
|
|
224
227
|
action: { kind: 'read', accessRule: [] },
|
|
225
228
|
} as any
|
|
226
229
|
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
assert.
|
|
230
|
+
const rules = runtimeModel.getEffectiveRules(mockAction)
|
|
231
|
+
assert.equal(rules.preFetch.mandatoryRules.length, 1)
|
|
232
|
+
assert.equal(rules.preFetch.mandatoryRules[0].type, 'allowAuthenticated')
|
|
233
|
+
assert.equal(rules.preFetch.permissionRules.length, 0)
|
|
230
234
|
}).tags(['@modeling', '@runtime', '@authorization'])
|
|
231
235
|
|
|
232
|
-
test('
|
|
236
|
+
test('getEffectiveRules - defaults to preFetch phase if none specified', async ({ assert }) => {
|
|
233
237
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
234
238
|
const baseModel = new ApiModel({ key: 'api-1' }, domain)
|
|
235
239
|
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
236
240
|
|
|
237
241
|
const mockAction = {
|
|
238
|
-
|
|
242
|
+
exposure: {
|
|
239
243
|
accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
|
|
240
244
|
parent: undefined,
|
|
241
245
|
},
|
|
242
246
|
action: { kind: 'read', accessRule: [] },
|
|
243
247
|
} as any
|
|
244
248
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
assert.
|
|
249
|
+
const rules = runtimeModel.getEffectiveRules(mockAction)
|
|
250
|
+
assert.equal(rules.preFetch.permissionRules.length, 1)
|
|
251
|
+
assert.equal(rules.preFetch.permissionRules[0].type, 'allowPublic')
|
|
248
252
|
}).tags(['@modeling', '@runtime', '@authorization'])
|
|
249
253
|
|
|
250
|
-
test('
|
|
254
|
+
test('getEffectiveRules - orders from most specific to most general', async ({ assert }) => {
|
|
251
255
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
252
|
-
const baseModel = new ApiModel({
|
|
256
|
+
const baseModel = new ApiModel({ accessRule: [{ type: 'allowAuthenticated' }] }, domain)
|
|
253
257
|
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
254
258
|
|
|
255
259
|
const mockAction = {
|
|
256
|
-
|
|
260
|
+
exposure: {
|
|
257
261
|
accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
|
|
258
262
|
parent: undefined,
|
|
259
263
|
},
|
|
@@ -263,77 +267,19 @@ test.group('RuntimeApiModel', () => {
|
|
|
263
267
|
},
|
|
264
268
|
} as any
|
|
265
269
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
|
|
273
|
-
assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic', 'allowAuthenticated'])
|
|
274
|
-
}).tags(['@modeling', '@runtime', '@authorization'])
|
|
275
|
-
|
|
276
|
-
test('evaluateAccess - short-circuits on explicit allow or deny in permission phase', async ({ assert }) => {
|
|
277
|
-
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
278
|
-
const baseModel = new ApiModel({ key: 'api-1', accessRule: [{ type: 'allowAuthenticated' }] }, domain)
|
|
279
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
280
|
-
|
|
281
|
-
const mockAction = {
|
|
282
|
-
entity: {
|
|
283
|
-
accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
|
|
284
|
-
parent: undefined,
|
|
285
|
-
},
|
|
286
|
-
action: {
|
|
287
|
-
kind: 'read',
|
|
288
|
-
accessRule: [{ type: 'matchEmailDomain', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
|
|
289
|
-
},
|
|
290
|
-
} as any
|
|
291
|
-
|
|
292
|
-
const evaluatedOrder: string[] = []
|
|
293
|
-
const evaluator = async (rule: any) => {
|
|
294
|
-
evaluatedOrder.push(rule.type)
|
|
295
|
-
if (rule.type === 'allowPublic') return true
|
|
296
|
-
return undefined
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
|
|
300
|
-
assert.isTrue(result)
|
|
301
|
-
// Should not reach the api-rule
|
|
302
|
-
assert.deepEqual(evaluatedOrder, ['matchEmailDomain', 'allowPublic'])
|
|
303
|
-
}).tags(['@modeling', '@runtime', '@authorization'])
|
|
304
|
-
|
|
305
|
-
test('evaluateAccess - passes mandatory phase and uses permission phase result', async ({ assert }) => {
|
|
306
|
-
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
307
|
-
const baseModel = new ApiModel(
|
|
308
|
-
{ key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
|
|
309
|
-
domain
|
|
270
|
+
const rules = runtimeModel.getEffectiveRules(mockAction)
|
|
271
|
+
assert.equal(rules.preFetch.permissionRules.length, 3)
|
|
272
|
+
assert.deepEqual(
|
|
273
|
+
rules.preFetch.permissionRules.map((r) => r.type),
|
|
274
|
+
['matchEmailDomain', 'allowPublic', 'allowAuthenticated']
|
|
310
275
|
)
|
|
311
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
312
|
-
|
|
313
|
-
const mockAction = {
|
|
314
|
-
entity: {
|
|
315
|
-
accessRule: [{ type: 'allowPublic', metadata: { read: AccessRuleExecutionPhase.PRE_FETCH } }],
|
|
316
|
-
parent: undefined,
|
|
317
|
-
},
|
|
318
|
-
action: { kind: 'read', accessRule: [] },
|
|
319
|
-
} as any
|
|
320
|
-
|
|
321
|
-
const evaluator = async (rule: any) => {
|
|
322
|
-
if (rule.mandatory) return true // passes mandatory check
|
|
323
|
-
if (rule.type === 'allowPublic') return false // explicitly denies
|
|
324
|
-
return undefined
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const result = await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
|
|
328
|
-
assert.isFalse(result) // Denied by permission rule
|
|
329
276
|
}).tags(['@modeling', '@runtime', '@authorization'])
|
|
330
277
|
|
|
331
|
-
test('
|
|
278
|
+
test('getEffectiveRules - shadows parent rules of the same type', async ({ assert }) => {
|
|
332
279
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
333
280
|
const model = domain.addModel({ key: 'mod-1' })
|
|
334
281
|
const entity = model.addEntity({ key: 'ent-1' })
|
|
335
282
|
|
|
336
|
-
// Create base model with exposing and actions directly in schema
|
|
337
283
|
const baseModel = new ApiModel(
|
|
338
284
|
{
|
|
339
285
|
key: 'api-1',
|
|
@@ -361,19 +307,6 @@ test.group('RuntimeApiModel', () => {
|
|
|
361
307
|
domain
|
|
362
308
|
)
|
|
363
309
|
|
|
364
|
-
// Hierarchy will be:
|
|
365
|
-
// Action: allowAuthenticated (mandatory)
|
|
366
|
-
// Entity: allowPublic, matchEmailDomain (mandatory)
|
|
367
|
-
// API: allowAuthenticated, matchEmailDomain
|
|
368
|
-
|
|
369
|
-
// Effective Rules (with shadowing):
|
|
370
|
-
// Action's allowAuthenticated (mandatory) shadows API's allowAuthenticated
|
|
371
|
-
// Entity's matchEmailDomain (mandatory) shadows API's matchEmailDomain
|
|
372
|
-
// Entity's allowPublic is kept
|
|
373
|
-
|
|
374
|
-
// Mandatory: allowAuthenticated, matchEmailDomain
|
|
375
|
-
// Permission: allowPublic
|
|
376
|
-
|
|
377
310
|
const schema: RuntimeApiModelSchema = {
|
|
378
311
|
...baseModel.toJSON(),
|
|
379
312
|
routingMap: {
|
|
@@ -384,16 +317,15 @@ test.group('RuntimeApiModel', () => {
|
|
|
384
317
|
const runtimeModel = new RuntimeApiModel(schema, domain.toJSON())
|
|
385
318
|
const mockAction = runtimeModel.lookupAction('GET', '/test')!
|
|
386
319
|
|
|
387
|
-
const
|
|
388
|
-
const evaluator: RuleEvaluator = async (rule: AccessRule): Promise<boolean | undefined> => {
|
|
389
|
-
evaluatedOrder.push(`${rule.type}${rule.mandatory ? '-mandatory' : ''}`)
|
|
390
|
-
if (rule.mandatory) return true
|
|
391
|
-
return undefined
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
await runtimeModel.evaluateAccess(mockAction, evaluator, AccessRuleExecutionPhase.PRE_FETCH)
|
|
320
|
+
const rules = runtimeModel.getEffectiveRules(mockAction)
|
|
395
321
|
|
|
396
|
-
|
|
397
|
-
|
|
322
|
+
assert.deepEqual(
|
|
323
|
+
rules.preFetch.mandatoryRules.map((r) => r.type),
|
|
324
|
+
['allowAuthenticated', 'matchEmailDomain']
|
|
325
|
+
)
|
|
326
|
+
assert.deepEqual(
|
|
327
|
+
rules.preFetch.permissionRules.map((r) => r.type),
|
|
328
|
+
['allowPublic']
|
|
329
|
+
)
|
|
398
330
|
}).tags(['@modeling', '@runtime', '@authorization'])
|
|
399
331
|
})
|