cabloy 5.1.239 → 5.1.241
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/.cabloy-version +1 -1
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/repo-docs/backend/openapi-guide.md +18 -0
- package/repo-docs/backend/rate-limit-guide.md +54 -9
- package/repo-e2e/specs/home-user-account.spec.ts +53 -8
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +16 -141
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/dto/operatorContext.ts +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/service/operator.ts +1 -0
- package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/operatorAccess.test.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/accountCurrent.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/dto/accountProfileUpdate.ts +9 -2
- package/vona/src/suite/a-home/modules/home-user/src/dto/passportUser.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/entity/user.ts +3 -0
- package/vona/src/suite/a-home/modules/home-user/src/service/account.ts +7 -3
- package/vona/src/suite/a-home/modules/home-user/src/service/userAdapter.ts +1 -0
- package/vona/src/suite/a-home/modules/home-user/test/account.test.ts +133 -8
- package/vona/src/suite/a-home/modules/home-user/test/accountOpenapi.test.ts +32 -5
- package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +159 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-hmr/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-hmr/src/service/hmr.ts +5 -12
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/src/bean/bean.permission.ts +54 -8
- package/vona/src/suite-vendor/a-vona/modules/a-permission/test/permission.test.ts +228 -16
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/.metadata/index.ts +26 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/bean/middlewareSystem.aggregate.ts +58 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/service/rateLimit.ts +25 -5
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/types/rateLimit.ts +5 -0
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/test/aggregate.test.ts +247 -0
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/user.ts +1 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/schemas.ts +68 -52
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/api/openapi/types.ts +530 -344
- package/zova/src/suite/a-commerce/modules/commerce-siteadmin/src/page/dashboard/controller.tsx +7 -1
- package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +6 -1
- package/zova/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +1 -0
- package/zova/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +1 -0
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +1 -1
- package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +3 -1
- package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +4 -0
- package/zova/src/suite/a-home/modules/home-user/src/config/locale/en-us.ts +1 -0
- package/zova/src/suite/a-home/modules/home-user/src/config/locale/zh-cn.ts +1 -0
- package/zova/src/suite/a-home/modules/home-user/src/model/account.ts +2 -1
- package/zova/src/suite/a-home/modules/home-user/src/page/account/controller.tsx +17 -3
|
@@ -11,7 +11,7 @@ import type { ContextRoute, IRecordResourceNameToRoutePathItem } from 'vona-modu
|
|
|
11
11
|
import { appResource, BeanBase, beanFullNameFromOnionName } from 'vona';
|
|
12
12
|
import { Bean } from 'vona-module-a-bean';
|
|
13
13
|
import { Caching } from 'vona-module-a-caching';
|
|
14
|
-
import { clearRbacDecision,
|
|
14
|
+
import { clearRbacDecision, getRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
|
|
15
15
|
import {
|
|
16
16
|
composeGuards,
|
|
17
17
|
getCacheControllerRoutes,
|
|
@@ -107,6 +107,23 @@ export class BeanPermission extends BeanBase {
|
|
|
107
107
|
return typeof permission === 'boolean' ? permission : permission.allowed;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Checks current guards without permission events, result caches or matcher projection.
|
|
112
|
+
* A true result does not authorize individual records or prepare data scope/owner values.
|
|
113
|
+
* The target action's RBAC decision is not retained; scoped operations must authorize separately.
|
|
114
|
+
*/
|
|
115
|
+
public async checkPermissionActionGuards(
|
|
116
|
+
resource: keyof IResourceRecord,
|
|
117
|
+
actionKey: string,
|
|
118
|
+
): Promise<boolean> {
|
|
119
|
+
if (!Object.hasOwn(recordResourceNameToRoutePath, resource)) return false;
|
|
120
|
+
const routePathInfo = recordResourceNameToRoutePath[resource];
|
|
121
|
+
if (!routePathInfo || !appResource.getBean(routePathInfo.controller)) return false;
|
|
122
|
+
const route = this._getControllerActionRoute(resource, actionKey);
|
|
123
|
+
if (!route?.route?.meta || !this._matchPassportMeta(route.route.meta)) return false;
|
|
124
|
+
return await this._evaluatePermissionAction(route, true);
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
public async retrievePermissionAction(
|
|
111
128
|
resource: keyof IResourceRecord,
|
|
112
129
|
actionKey: string,
|
|
@@ -201,12 +218,17 @@ export class BeanPermission extends BeanBase {
|
|
|
201
218
|
return true;
|
|
202
219
|
}
|
|
203
220
|
|
|
204
|
-
private async _evaluatePermissionAction(route: ContextRoute): Promise<
|
|
221
|
+
private async _evaluatePermissionAction(route: ContextRoute, guardsOnly: true): Promise<boolean>;
|
|
222
|
+
private async _evaluatePermissionAction(route: ContextRoute): Promise<IOpenapiPermissionAction>;
|
|
223
|
+
private async _evaluatePermissionAction(
|
|
224
|
+
route: ContextRoute,
|
|
225
|
+
guardsOnly = false,
|
|
226
|
+
): Promise<IOpenapiPermissionAction> {
|
|
205
227
|
const ctx = this.ctx as any;
|
|
206
|
-
const
|
|
228
|
+
const routeDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'route');
|
|
229
|
+
const innerAccessDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, 'innerAccess');
|
|
207
230
|
const innerAccessPrevious = ctx.innerAccess;
|
|
208
|
-
const
|
|
209
|
-
const rbacDecisionPrevious = ctx[SymbolRbacDecision];
|
|
231
|
+
const rbacDecisionDescriptorPrevious = Object.getOwnPropertyDescriptor(ctx, SymbolRbacDecision);
|
|
210
232
|
try {
|
|
211
233
|
ctx.route = route;
|
|
212
234
|
ctx.innerAccess = false;
|
|
@@ -214,19 +236,43 @@ export class BeanPermission extends BeanBase {
|
|
|
214
236
|
const result = await composeGuards(this.app, route)(this.ctx);
|
|
215
237
|
if (result === false) return false;
|
|
216
238
|
if (!route.route.meta?.[BeanFullNameGuardRbac]) return true;
|
|
239
|
+
if (guardsOnly) {
|
|
240
|
+
const action = this.bean.rbacCatalog.getAction(route);
|
|
241
|
+
const decision = getRbacDecision(this.ctx);
|
|
242
|
+
return Boolean(
|
|
243
|
+
action &&
|
|
244
|
+
Object.hasOwn(ctx, SymbolRbacDecision) &&
|
|
245
|
+
decision?.allowed === true &&
|
|
246
|
+
decision.actionKey === action.actionKey &&
|
|
247
|
+
decision.action?.actionKey === action.actionKey &&
|
|
248
|
+
decision.action.actionInheritKey === action.actionInheritKey &&
|
|
249
|
+
decision.action.controllerBeanFullName === action.controllerBeanFullName &&
|
|
250
|
+
decision.action.action === action.action,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
217
253
|
const rbacScopeCurrent = await this.bean.rbacScope.current();
|
|
218
254
|
return rbacScopeCurrent.permissionProjection();
|
|
219
255
|
} catch (err: any) {
|
|
220
256
|
if ([401, 403].includes(err?.code)) return false;
|
|
221
257
|
throw err;
|
|
222
258
|
} finally {
|
|
223
|
-
if (
|
|
224
|
-
|
|
259
|
+
if (rbacDecisionDescriptorPrevious) {
|
|
260
|
+
Object.defineProperty(ctx, SymbolRbacDecision, rbacDecisionDescriptorPrevious);
|
|
225
261
|
} else {
|
|
226
262
|
clearRbacDecision(ctx);
|
|
227
263
|
}
|
|
228
|
-
|
|
264
|
+
if (routeDescriptorPrevious) {
|
|
265
|
+
Object.defineProperty(ctx, 'route', routeDescriptorPrevious);
|
|
266
|
+
} else {
|
|
267
|
+
delete ctx.route;
|
|
268
|
+
}
|
|
269
|
+
// Vona's inherited innerAccess accessor owns its value in a context symbol.
|
|
229
270
|
ctx.innerAccess = innerAccessPrevious;
|
|
271
|
+
if (innerAccessDescriptorPrevious) {
|
|
272
|
+
Object.defineProperty(ctx, 'innerAccess', innerAccessDescriptorPrevious);
|
|
273
|
+
} else {
|
|
274
|
+
delete ctx.innerAccess;
|
|
275
|
+
}
|
|
230
276
|
}
|
|
231
277
|
}
|
|
232
278
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import type { VonaContext } from 'vona';
|
|
1
2
|
import type { ICachingActionKeyInfo } from 'vona-module-a-caching';
|
|
2
3
|
import type { IRbacPolicyDecision } from 'vona-module-a-rbac';
|
|
4
|
+
import type { ContextRoute } from 'vona-module-a-web';
|
|
3
5
|
|
|
4
6
|
import assert from 'node:assert';
|
|
5
7
|
import { describe, it } from 'node:test';
|
|
6
|
-
import { beanFullNameFromOnionName } from 'vona';
|
|
8
|
+
import { appResource, beanFullNameFromOnionName } from 'vona';
|
|
9
|
+
import { app } from 'vona-mock';
|
|
7
10
|
import { getRbacDecision, setRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
|
|
11
|
+
import { getCacheControllerRoutes, recordResourceNameToRoutePath } from 'vona-module-a-web';
|
|
8
12
|
|
|
9
13
|
import { BeanPermission } from '../src/bean/bean.permission.ts';
|
|
10
14
|
|
|
@@ -51,6 +55,64 @@ function createCachePermission(clearedCacheNames: string[]) {
|
|
|
51
55
|
return permission;
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
const BeanFullNameGuardRbac = beanFullNameFromOnionName('a-rbac:rbac', 'guard');
|
|
59
|
+
const GuardResource = 'test-rest:product';
|
|
60
|
+
|
|
61
|
+
async function withGuardPermission(
|
|
62
|
+
guard: (ctx: VonaContext) => Promise<boolean | void>,
|
|
63
|
+
callback: (fixture: {
|
|
64
|
+
permission: BeanPermission;
|
|
65
|
+
ctx: VonaContext;
|
|
66
|
+
passport: { isAuthenticated: boolean; isAccountActive: boolean; isActivated: boolean };
|
|
67
|
+
action: IRbacPolicyDecision['action'];
|
|
68
|
+
}) => Promise<void>,
|
|
69
|
+
rbac = false,
|
|
70
|
+
) {
|
|
71
|
+
await app.bean.executor.mockCtx(async () => {
|
|
72
|
+
const controller = recordResourceNameToRoutePath[GuardResource].controller;
|
|
73
|
+
const controllerBeanFullName = appResource.getBean(controller)!.beanFullName;
|
|
74
|
+
const route = {
|
|
75
|
+
controller,
|
|
76
|
+
controllerBeanFullName,
|
|
77
|
+
action: 'create',
|
|
78
|
+
route: { meta: rbac ? { [BeanFullNameGuardRbac]: {} } : {} },
|
|
79
|
+
} as ContextRoute;
|
|
80
|
+
const action: IRbacPolicyDecision['action'] = {
|
|
81
|
+
actionKey: `${controllerBeanFullName}#create`,
|
|
82
|
+
actionInheritKey: `${controllerBeanFullName}#update`,
|
|
83
|
+
controllerBeanFullName,
|
|
84
|
+
action: 'create',
|
|
85
|
+
route,
|
|
86
|
+
options: {},
|
|
87
|
+
};
|
|
88
|
+
const unexpected = () =>
|
|
89
|
+
assert.fail('guard-only checks must not use permission retrieval or scope projection');
|
|
90
|
+
const fixtureApp = {
|
|
91
|
+
meta: {},
|
|
92
|
+
bean: { onion: { guard: { compose: () => guard } } },
|
|
93
|
+
};
|
|
94
|
+
getCacheControllerRoutes(fixtureApp as never)[controllerBeanFullName] = [route];
|
|
95
|
+
const passport = { isAuthenticated: true, isAccountActive: true, isActivated: true };
|
|
96
|
+
const permission = Object.create(BeanPermission.prototype) as BeanPermission;
|
|
97
|
+
Object.defineProperties(permission, {
|
|
98
|
+
app: { value: fixtureApp },
|
|
99
|
+
ctx: { value: app.ctx },
|
|
100
|
+
bean: {
|
|
101
|
+
value: {
|
|
102
|
+
passport,
|
|
103
|
+
rbacCatalog: { getAction: () => action },
|
|
104
|
+
rbacScope: { current: unexpected },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
scope: { value: { event: { retrievePermissionAction: { emit: unexpected } } } },
|
|
108
|
+
retrievePermissionAction: { value: unexpected },
|
|
109
|
+
retrievePermissionActionByRoles: { value: unexpected },
|
|
110
|
+
retrievePermissionActionByUser: { value: unexpected },
|
|
111
|
+
});
|
|
112
|
+
await callback({ permission, ctx: app.ctx, passport, action });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
54
116
|
describe('permission.test.ts', { concurrency: false }, () => {
|
|
55
117
|
it('normalizes role cache keys by role identity', () => {
|
|
56
118
|
const first = createPermission({
|
|
@@ -62,10 +124,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
62
124
|
roleIds: ['role-a', 'role-b'],
|
|
63
125
|
});
|
|
64
126
|
|
|
65
|
-
const info = cacheInfo('
|
|
127
|
+
const info = cacheInfo('test:resource', 'select');
|
|
66
128
|
assert.equal(
|
|
67
129
|
(first as any).retrievePermissionActionByRolesCacheKey(info),
|
|
68
|
-
'action:
|
|
130
|
+
'action:test:resource:select:roles:role-a,role-b',
|
|
69
131
|
);
|
|
70
132
|
assert.equal(
|
|
71
133
|
(first as any).retrievePermissionActionByRolesCacheKey(info),
|
|
@@ -78,7 +140,7 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
78
140
|
userId: 'user-1',
|
|
79
141
|
roleIds: ['role-a'],
|
|
80
142
|
});
|
|
81
|
-
const info = cacheInfo('
|
|
143
|
+
const info = cacheInfo('test:resource', 'select');
|
|
82
144
|
|
|
83
145
|
assert.notEqual(
|
|
84
146
|
(permission as any).retrievePermissionActionByRolesCacheKey(info),
|
|
@@ -100,10 +162,10 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
100
162
|
roleIds: ['role-b'],
|
|
101
163
|
});
|
|
102
164
|
|
|
103
|
-
const info = cacheInfo('
|
|
165
|
+
const info = cacheInfo('test:resource', 'select');
|
|
104
166
|
assert.equal(
|
|
105
167
|
(first as any).retrievePermissionActionByUserCacheKey(info),
|
|
106
|
-
'action:
|
|
168
|
+
'action:test:resource:select:user:user-1:roles:role-a',
|
|
107
169
|
);
|
|
108
170
|
assert.notEqual(
|
|
109
171
|
(first as any).retrievePermissionActionByUserCacheKey(info),
|
|
@@ -152,24 +214,21 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
152
214
|
});
|
|
153
215
|
|
|
154
216
|
assert.equal(
|
|
155
|
-
await permission.retrievePermissionAction('
|
|
217
|
+
await permission.retrievePermissionAction('test:resource' as never, 'ordinary'),
|
|
156
218
|
false,
|
|
157
219
|
);
|
|
158
|
-
assert.equal(
|
|
159
|
-
await permission.retrievePermissionAction('training-student:student', 'rbac'),
|
|
160
|
-
true,
|
|
161
|
-
);
|
|
220
|
+
assert.equal(await permission.retrievePermissionAction('test:resource' as never, 'rbac'), true);
|
|
162
221
|
assert.deepEqual(calls, ['roles', 'user']);
|
|
163
222
|
});
|
|
164
223
|
|
|
165
224
|
it('isolates resource permission snapshots by user', () => {
|
|
166
225
|
const first = createPermission({ userId: 'user-1' });
|
|
167
226
|
const otherUser = createPermission({ userId: 'user-2' });
|
|
168
|
-
const info = cacheInfo('
|
|
227
|
+
const info = cacheInfo('test:resource');
|
|
169
228
|
|
|
170
229
|
assert.equal(
|
|
171
230
|
(first as any).retrievePermissionsCacheKey(info),
|
|
172
|
-
'user:
|
|
231
|
+
'user:test:resource:user:user-1',
|
|
173
232
|
);
|
|
174
233
|
assert.notEqual(
|
|
175
234
|
(first as any).retrievePermissionsCacheKey(info),
|
|
@@ -307,6 +366,159 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
307
366
|
assert.equal(ctx.innerAccess, true);
|
|
308
367
|
});
|
|
309
368
|
|
|
369
|
+
it('checks current guards without permission events, result caches, or projection', async () => {
|
|
370
|
+
let allowed = true;
|
|
371
|
+
let calls = 0;
|
|
372
|
+
await withGuardPermission(
|
|
373
|
+
async () => {
|
|
374
|
+
calls++;
|
|
375
|
+
return allowed;
|
|
376
|
+
},
|
|
377
|
+
async ({ permission }) => {
|
|
378
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
|
|
379
|
+
allowed = false;
|
|
380
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), false);
|
|
381
|
+
assert.equal(calls, 2);
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('fails closed before guards for unknown routes and Passport admission failures', async () => {
|
|
387
|
+
await withGuardPermission(
|
|
388
|
+
async () => assert.fail('guards must not run before admission'),
|
|
389
|
+
async ({ permission, passport }) => {
|
|
390
|
+
assert.equal(
|
|
391
|
+
await permission.checkPermissionActionGuards('test:missing' as never, 'create'),
|
|
392
|
+
false,
|
|
393
|
+
);
|
|
394
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'missing'), false);
|
|
395
|
+
passport.isAuthenticated = false;
|
|
396
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), false);
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('converts guard authorization errors to false and propagates unexpected errors', async () => {
|
|
402
|
+
let failure: Error;
|
|
403
|
+
await withGuardPermission(
|
|
404
|
+
async () => {
|
|
405
|
+
throw failure;
|
|
406
|
+
},
|
|
407
|
+
async ({ permission }) => {
|
|
408
|
+
for (const code of [401, 403]) {
|
|
409
|
+
failure = Object.assign(new Error('authorization failure'), { code });
|
|
410
|
+
assert.equal(
|
|
411
|
+
await permission.checkPermissionActionGuards(GuardResource, 'create'),
|
|
412
|
+
false,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
failure = new Error('unexpected guard failure');
|
|
416
|
+
await assert.rejects(
|
|
417
|
+
() => permission.checkPermissionActionGuards(GuardResource, 'create'),
|
|
418
|
+
failure,
|
|
419
|
+
);
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('requires a fresh allowed RBAC decision matching the full catalog action identity', async () => {
|
|
425
|
+
let decision: IRbacPolicyDecision | undefined;
|
|
426
|
+
await withGuardPermission(
|
|
427
|
+
async ctx => {
|
|
428
|
+
assert.equal(getRbacDecision(ctx), undefined);
|
|
429
|
+
if (decision) setRbacDecision(ctx, decision);
|
|
430
|
+
return true;
|
|
431
|
+
},
|
|
432
|
+
async ({ permission, ctx, action }) => {
|
|
433
|
+
const valid: IRbacPolicyDecision = {
|
|
434
|
+
allowed: true,
|
|
435
|
+
actionKey: action.actionKey,
|
|
436
|
+
action: { ...action },
|
|
437
|
+
terms: [{ dataScope: 'all' }],
|
|
438
|
+
};
|
|
439
|
+
setRbacDecision(ctx, valid);
|
|
440
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), false);
|
|
441
|
+
assert.equal(getRbacDecision(ctx), valid);
|
|
442
|
+
for (const invalid of [
|
|
443
|
+
{ ...valid, allowed: false },
|
|
444
|
+
{ ...valid, actionKey: 'other#action' },
|
|
445
|
+
...[
|
|
446
|
+
{ actionKey: 'other#action' },
|
|
447
|
+
{ actionInheritKey: undefined },
|
|
448
|
+
{ controllerBeanFullName: 'other.controller' },
|
|
449
|
+
{ action: 'other' },
|
|
450
|
+
].map(mismatch => ({ ...valid, action: { ...action, ...mismatch } })),
|
|
451
|
+
]) {
|
|
452
|
+
decision = invalid;
|
|
453
|
+
assert.equal(
|
|
454
|
+
await permission.checkPermissionActionGuards(GuardResource, 'create'),
|
|
455
|
+
false,
|
|
456
|
+
);
|
|
457
|
+
assert.equal(getRbacDecision(ctx), valid);
|
|
458
|
+
}
|
|
459
|
+
decision = valid;
|
|
460
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
|
|
461
|
+
assert.equal(getRbacDecision(ctx), valid);
|
|
462
|
+
delete ctx[SymbolRbacDecision];
|
|
463
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
|
|
464
|
+
assert.equal(Object.hasOwn(ctx, SymbolRbacDecision), false);
|
|
465
|
+
},
|
|
466
|
+
true,
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('restores property descriptors and inherited innerAccess after success and failure', async () => {
|
|
471
|
+
let failure: Error | undefined;
|
|
472
|
+
await withGuardPermission(
|
|
473
|
+
async ctx => {
|
|
474
|
+
assert.equal(ctx.route.action, 'create');
|
|
475
|
+
assert.equal(ctx.innerAccess, false);
|
|
476
|
+
assert.equal(getRbacDecision(ctx), undefined);
|
|
477
|
+
if (failure) throw failure;
|
|
478
|
+
return true;
|
|
479
|
+
},
|
|
480
|
+
async ({ permission, ctx, action }) => {
|
|
481
|
+
assert.equal(Object.hasOwn(ctx, 'innerAccess'), false);
|
|
482
|
+
ctx.innerAccess = true;
|
|
483
|
+
Object.defineProperty(ctx, 'route', {
|
|
484
|
+
value: { action: 'previous' },
|
|
485
|
+
writable: true,
|
|
486
|
+
configurable: true,
|
|
487
|
+
enumerable: false,
|
|
488
|
+
});
|
|
489
|
+
Object.defineProperty(ctx, SymbolRbacDecision, {
|
|
490
|
+
value: {
|
|
491
|
+
allowed: true,
|
|
492
|
+
actionKey: action.actionKey,
|
|
493
|
+
action,
|
|
494
|
+
terms: [{ dataScope: 'all' }],
|
|
495
|
+
},
|
|
496
|
+
writable: true,
|
|
497
|
+
configurable: true,
|
|
498
|
+
enumerable: false,
|
|
499
|
+
});
|
|
500
|
+
const descriptors = Object.getOwnPropertyDescriptors(ctx);
|
|
501
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
|
|
502
|
+
assert.deepEqual(Object.getOwnPropertyDescriptors(ctx), descriptors);
|
|
503
|
+
assert.equal(ctx.innerAccess, true);
|
|
504
|
+
failure = new Error('guard failure');
|
|
505
|
+
await assert.rejects(
|
|
506
|
+
() => permission.checkPermissionActionGuards(GuardResource, 'create'),
|
|
507
|
+
failure,
|
|
508
|
+
);
|
|
509
|
+
assert.deepEqual(Object.getOwnPropertyDescriptors(ctx), descriptors);
|
|
510
|
+
assert.equal(ctx.innerAccess, true);
|
|
511
|
+
|
|
512
|
+
delete (ctx as any).route;
|
|
513
|
+
delete ctx[SymbolRbacDecision];
|
|
514
|
+
const absentDescriptors = Object.getOwnPropertyDescriptors(ctx);
|
|
515
|
+
failure = undefined;
|
|
516
|
+
assert.equal(await permission.checkPermissionActionGuards(GuardResource, 'create'), true);
|
|
517
|
+
assert.deepEqual(Object.getOwnPropertyDescriptors(ctx), absentDescriptors);
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
|
|
310
522
|
it('reduces an RBAC action projection to its final boolean result', async () => {
|
|
311
523
|
const allowed = createPermission({});
|
|
312
524
|
const denied = createPermission({});
|
|
@@ -333,8 +545,8 @@ describe('permission.test.ts', { concurrency: false }, () => {
|
|
|
333
545
|
retrievePermissionAction: { value: async () => true },
|
|
334
546
|
});
|
|
335
547
|
|
|
336
|
-
assert.equal(await allowed.checkPermissionAction('
|
|
337
|
-
assert.equal(await denied.checkPermissionAction('
|
|
338
|
-
assert.equal(await legacy.checkPermissionAction('
|
|
548
|
+
assert.equal(await allowed.checkPermissionAction('test:resource' as never, 'select'), true);
|
|
549
|
+
assert.equal(await denied.checkPermissionAction('test:resource' as never, 'select'), false);
|
|
550
|
+
assert.equal(await legacy.checkPermissionAction('test:resource' as never, 'select'), true);
|
|
339
551
|
});
|
|
340
552
|
});
|
|
@@ -25,6 +25,32 @@ declare module 'vona-module-a-ratelimit' {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
/** interceptor: end */
|
|
28
|
+
/** middlewareSystem: begin */
|
|
29
|
+
export * from '../bean/middlewareSystem.aggregate.ts';
|
|
30
|
+
import type { IMiddlewareSystemOptionsAggregate } from '../bean/middlewareSystem.aggregate.ts';
|
|
31
|
+
import 'vona-module-a-aspect';
|
|
32
|
+
declare module 'vona-module-a-aspect' {
|
|
33
|
+
|
|
34
|
+
export interface IMiddlewareSystemRecord {
|
|
35
|
+
'a-ratelimit:aggregate': IMiddlewareSystemOptionsAggregate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
declare module 'vona-module-a-ratelimit' {
|
|
41
|
+
|
|
42
|
+
export interface MiddlewareSystemAggregate {
|
|
43
|
+
/** @internal */
|
|
44
|
+
get scope(): ScopeModuleARatelimit;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MiddlewareSystemAggregate {
|
|
48
|
+
get $beanFullName(): 'a-ratelimit.middlewareSystem.aggregate';
|
|
49
|
+
get $onionName(): 'a-ratelimit:aggregate';
|
|
50
|
+
get $onionOptions(): IMiddlewareSystemOptionsAggregate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** middlewareSystem: end */
|
|
28
54
|
/** service: begin */
|
|
29
55
|
export * from '../service/rateLimit.ts';
|
|
30
56
|
|
package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/src/bean/middlewareSystem.aggregate.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Next } from 'vona';
|
|
2
|
+
import type {
|
|
3
|
+
IDecoratorMiddlewareSystemOptions,
|
|
4
|
+
IMiddlewareSystemExecute,
|
|
5
|
+
} from 'vona-module-a-aspect';
|
|
6
|
+
|
|
7
|
+
import { BeanBase } from 'vona';
|
|
8
|
+
import { MiddlewareSystem } from 'vona-module-a-aspect';
|
|
9
|
+
|
|
10
|
+
import type { IRateLimitAggregatePolicy, IRateLimitResult } from '../types/rateLimit.ts';
|
|
11
|
+
|
|
12
|
+
export interface IMiddlewareSystemOptionsAggregate extends IDecoratorMiddlewareSystemOptions {
|
|
13
|
+
rateLimit?: IRateLimitAggregatePolicy;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@MiddlewareSystem<IMiddlewareSystemOptionsAggregate>({
|
|
17
|
+
enable: false,
|
|
18
|
+
dependencies: 'a-logger:httpLog',
|
|
19
|
+
dependents: ['a-metrics:health', 'a-instance:app'],
|
|
20
|
+
})
|
|
21
|
+
export class MiddlewareSystemAggregate extends BeanBase implements IMiddlewareSystemExecute {
|
|
22
|
+
async execute(options: IMiddlewareSystemOptionsAggregate, next: Next) {
|
|
23
|
+
const policy = options.rateLimit;
|
|
24
|
+
if (!policy) throw new Error('rate-limit aggregate policy is required when enabled');
|
|
25
|
+
// Validation is synchronous; only Redis admission failures enter the catch below.
|
|
26
|
+
const admission = this.scope.service.rateLimit.admitAggregate(policy);
|
|
27
|
+
let result: IRateLimitResult;
|
|
28
|
+
try {
|
|
29
|
+
result = await admission;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
this.$logger.warn({
|
|
32
|
+
event: 'rate_limit.redis_error',
|
|
33
|
+
scope: 'aggregate',
|
|
34
|
+
mode: policy.mode,
|
|
35
|
+
error: error instanceof Error ? error.name : 'Error',
|
|
36
|
+
});
|
|
37
|
+
if (policy.mode === 'observe') return next();
|
|
38
|
+
this._reject(503, 'Service Unavailable');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (result.allowed) return next();
|
|
42
|
+
this.$logger.warn({
|
|
43
|
+
event: policy.mode === 'observe' ? 'rate_limit.would_reject' : 'rate_limit.rejected',
|
|
44
|
+
scope: 'aggregate',
|
|
45
|
+
mode: policy.mode,
|
|
46
|
+
limit: policy.limit,
|
|
47
|
+
});
|
|
48
|
+
if (policy.mode === 'observe') return next();
|
|
49
|
+
this.ctx.set('Retry-After', String(result.retryAfterSeconds));
|
|
50
|
+
this._reject(429, 'Too Many Requests');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private _reject(status: number, message: string) {
|
|
54
|
+
this.ctx.status = status;
|
|
55
|
+
this.ctx.type = 'application/json';
|
|
56
|
+
this.ctx.body = { code: status, message };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -2,7 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { BeanBase, cast } from 'vona';
|
|
3
3
|
import { Service } from 'vona-module-a-bean';
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
IRateLimitAggregatePolicy,
|
|
7
|
+
IRateLimitPolicy,
|
|
8
|
+
IRateLimitResult,
|
|
9
|
+
} from '../types/rateLimit.ts';
|
|
6
10
|
|
|
7
11
|
@Service()
|
|
8
12
|
export class ServiceRateLimit extends BeanBase {
|
|
@@ -14,16 +18,32 @@ export class ServiceRateLimit extends BeanBase {
|
|
|
14
18
|
this._validatePolicy(policy);
|
|
15
19
|
const now = Date.now();
|
|
16
20
|
const key = this._createKey(policy, now);
|
|
17
|
-
|
|
21
|
+
return this._admit(key, policy.limit, policy.windowMs);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
admitAggregate(policy: IRateLimitAggregatePolicy): Promise<IRateLimitResult> {
|
|
25
|
+
if (!['observe', 'enforce'].includes(policy.mode)) {
|
|
26
|
+
throw new Error('rate-limit aggregate mode must be observe or enforce');
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isSafeInteger(policy.limit) || policy.limit < 1) {
|
|
29
|
+
throw new Error('rate-limit aggregate limit must be a positive safe integer');
|
|
30
|
+
}
|
|
31
|
+
const windowMs = 1_000;
|
|
32
|
+
const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
|
|
33
|
+
return this._admit(`rate-limit:aggregate:v1:${windowStart}`, policy.limit, windowMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async _admit(key: string, limit: number, windowMs: number): Promise<IRateLimitResult> {
|
|
37
|
+
const result = (await cast(this.clientRedis).rateLimitAdmit(key, String(windowMs))) as [
|
|
18
38
|
number | string,
|
|
19
39
|
number | string,
|
|
20
40
|
];
|
|
21
41
|
const current = Number(result[0]);
|
|
22
42
|
const resetAfterMs = Math.max(0, Number(result[1]));
|
|
23
43
|
return {
|
|
24
|
-
allowed: current <=
|
|
25
|
-
limit
|
|
26
|
-
remaining: Math.max(0,
|
|
44
|
+
allowed: current <= limit,
|
|
45
|
+
limit,
|
|
46
|
+
remaining: Math.max(0, limit - current),
|
|
27
47
|
resetAfterMs,
|
|
28
48
|
retryAfterSeconds: Math.max(1, Math.ceil(resetAfterMs / 1000)),
|
|
29
49
|
};
|