cabloy 5.1.241 → 5.1.242

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.
@@ -1,12 +1,209 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { ServiceCacheRedisBase, CacheRedis } from 'vona-module-a-cache';
2
3
 
3
4
  import type { ICaptchaDataCache } from '../types/captcha.ts';
5
+ import type { ICaptchaAuthorization } from '../types/captchaProvider.ts';
6
+
7
+ import {
8
+ captchaCasLua,
9
+ captchaConsumeLua,
10
+ captchaFinalizeLua,
11
+ captchaReserveLua,
12
+ } from '../lib/captchaLua.ts';
4
13
 
5
14
  export type TCacheRedisCaptchaKey = string;
6
15
  export type TCacheRedisCaptchaData = ICaptchaDataCache;
7
16
 
8
- @CacheRedis({ disableTransactionCompensate: true })
17
+ export interface ICaptchaSnapshot {
18
+ raw: string;
19
+ data: ICaptchaDataCache;
20
+ }
21
+
22
+ @CacheRedis({ disableTransactionCompensate: true, updateAgeOnGet: false })
9
23
  export class CacheRedisCaptcha extends ServiceCacheRedisBase<
10
24
  TCacheRedisCaptchaKey,
11
25
  TCacheRedisCaptchaData
12
- > {}
26
+ > {
27
+ async snapshot(id: string): Promise<ICaptchaSnapshot | undefined> {
28
+ const { redis, keys } = this._redisKeys([id]);
29
+ const raw = await redis.get(keys[0]);
30
+ return raw ? { raw, data: JSON.parse(raw) } : undefined;
31
+ }
32
+
33
+ async createImage(id: string, data: ICaptchaDataCache, ttl: number) {
34
+ this._validateTtl(ttl);
35
+ const { redis, keys } = this._redisKeys([id]);
36
+ return (await redis.set(keys[0], JSON.stringify(data), 'PX', ttl, 'NX')) === 'OK';
37
+ }
38
+
39
+ async compareAndSet(
40
+ id: string,
41
+ snapshot: ICaptchaSnapshot,
42
+ data?: ICaptchaDataCache,
43
+ ttl?: number,
44
+ ) {
45
+ if (ttl !== undefined) this._validateTtl(ttl);
46
+ const result = await this._eval(
47
+ captchaCasLua,
48
+ [id],
49
+ [snapshot.raw, data === undefined ? '' : JSON.stringify(data), ttl ?? 0],
50
+ );
51
+ return result === 1;
52
+ }
53
+
54
+ boundPrefix() {
55
+ const instance = createHash('sha256').update(String(this.__getInstanceIdScope())).digest('hex');
56
+ return `{captcha:${instance}}:`;
57
+ }
58
+
59
+ /** Opaque, instance-local cache keys, also available for precise test cleanup. */
60
+ boundKeys(
61
+ authorization: Pick<ICaptchaAuthorization, 'scope' | 'ip' | 'recipientLimits' | 'ipLimits'>,
62
+ ) {
63
+ const prefix = this.boundPrefix();
64
+ return {
65
+ pointer: `${prefix}current:${authorization.scope}`,
66
+ cooldown: `${prefix}cooldown:${authorization.scope}`,
67
+ budgets: [
68
+ ...authorization.recipientLimits.map(
69
+ limit => `${prefix}recipient:${authorization.scope}:${limit.windowMs}`,
70
+ ),
71
+ ...authorization.ipLimits.map(limit => `${prefix}ip:${authorization.ip}:${limit.windowMs}`),
72
+ ],
73
+ };
74
+ }
75
+
76
+ async reserveBound(id: string, data: ICaptchaDataCache, authorization: ICaptchaAuthorization) {
77
+ this._validateAuthorization(authorization);
78
+ this._validateBoundId(id);
79
+ const keys = this.boundKeys(authorization);
80
+ const policy = {
81
+ ttl: authorization.ttl,
82
+ cooldown: authorization.cooldown,
83
+ budgets: [...authorization.recipientLimits, ...authorization.ipLimits],
84
+ };
85
+ const result = (await this._eval(
86
+ captchaReserveLua,
87
+ [id, keys.pointer, keys.cooldown, ...keys.budgets],
88
+ [id, JSON.stringify(policy), JSON.stringify(data)],
89
+ )) as [number, string, number, number];
90
+ if (result[0] === -1) throw new Error('captcha identity collision');
91
+ if (result[0] === 0) {
92
+ return { reserved: false as const, retryAt: result[2], resendAt: result[3] };
93
+ }
94
+ if (result[0] !== 1 || !result[1]) throw new Error('invalid captcha reservation');
95
+ return {
96
+ reserved: true as const,
97
+ snapshot: { raw: result[1], data: JSON.parse(result[1]) as ICaptchaDataCache },
98
+ };
99
+ }
100
+
101
+ async finalizeBound(id: string, snapshot: ICaptchaSnapshot, data?: ICaptchaDataCache) {
102
+ this._validateBoundId(id);
103
+ const scope = snapshot.data.bound!.scope;
104
+ const pointer = `${this.boundPrefix()}current:${scope}`;
105
+ return (
106
+ (await this._eval(
107
+ captchaFinalizeLua,
108
+ [id, pointer],
109
+ [snapshot.raw, id, data === undefined ? '' : JSON.stringify(data)],
110
+ )) === 1
111
+ );
112
+ }
113
+
114
+ /** -1 asks the verifier to reread a competing wrong-attempt update. */
115
+ async consumeBound(id: string, snapshot: ICaptchaSnapshot, verified: boolean) {
116
+ this._validateBoundId(id);
117
+ const bound = snapshot.data.bound!;
118
+ const pointer = `${this.boundPrefix()}current:${bound.scope}`;
119
+ const next = {
120
+ ...snapshot.data,
121
+ bound: {
122
+ ...bound,
123
+ attemptsRemaining: bound.attemptsRemaining - 1,
124
+ version: bound.version + 1,
125
+ },
126
+ };
127
+ return (await this._eval(
128
+ captchaConsumeLua,
129
+ [id, pointer],
130
+ [snapshot.raw, id, verified ? '1' : '0', JSON.stringify(next)],
131
+ )) as number;
132
+ }
133
+
134
+ validateAuthorization(authorization: ICaptchaAuthorization) {
135
+ this._validateAuthorization(authorization);
136
+ }
137
+
138
+ private _validateAuthorization(authorization: ICaptchaAuthorization) {
139
+ if (!authorization || typeof authorization !== 'object')
140
+ throw new Error('invalid captcha authorization');
141
+ const { binding, scope, ip, ttl, cooldown, maxAttempts, recipientLimits, ipLimits } =
142
+ authorization;
143
+ const text = (value: unknown) =>
144
+ typeof value === 'string' && value.length > 0 && value.length <= 512;
145
+ const hash = (value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
146
+ if (
147
+ !binding ||
148
+ !text(binding.recipient) ||
149
+ !text(binding.purpose) ||
150
+ (binding.subject !== undefined && !text(binding.subject)) ||
151
+ !hash(scope) ||
152
+ !hash(ip)
153
+ ) {
154
+ throw new Error('invalid captcha binding');
155
+ }
156
+ this._validateTtl(ttl);
157
+ if (
158
+ !Number.isSafeInteger(cooldown) ||
159
+ cooldown < 0 ||
160
+ cooldown > Number.MAX_SAFE_INTEGER - Date.now() ||
161
+ maxAttempts !== 5
162
+ ) {
163
+ throw new Error('invalid captcha policy');
164
+ }
165
+ for (const limits of [recipientLimits, ipLimits]) {
166
+ if (!Array.isArray(limits) || limits.length === 0 || limits.length > 16)
167
+ throw new Error('invalid captcha limits');
168
+ const windows = new Set<number>();
169
+ for (const limit of limits) {
170
+ if (
171
+ !limit ||
172
+ !Number.isSafeInteger(limit.limit) ||
173
+ limit.limit <= 0 ||
174
+ windows.has(limit.windowMs)
175
+ ) {
176
+ throw new Error('invalid captcha limit');
177
+ }
178
+ this._validateTtl(limit.windowMs);
179
+ windows.add(limit.windowMs);
180
+ }
181
+ }
182
+ }
183
+
184
+ private _validateTtl(ttl: number) {
185
+ if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > Number.MAX_SAFE_INTEGER - Date.now()) {
186
+ throw new Error('invalid captcha ttl');
187
+ }
188
+ }
189
+
190
+ private _validateBoundId(id: string) {
191
+ if (
192
+ !id.startsWith(this.boundPrefix()) ||
193
+ !/^[a-f0-9-]{36}$/.test(id.slice(this.boundPrefix().length))
194
+ ) {
195
+ throw new Error('invalid bound captcha identity');
196
+ }
197
+ }
198
+
199
+ private _redisKeys(ids: string[]) {
200
+ const redis = this.__cacheInstance;
201
+ if (!redis) throw new Error('captcha cache not enabled');
202
+ return { redis, keys: ids.map(id => this.getRedisKey(id)!) };
203
+ }
204
+
205
+ private async _eval(lua: string, ids: string[], args: Array<string | number>) {
206
+ const { redis, keys } = this._redisKeys(ids);
207
+ return await redis.eval(lua, keys.length, ...keys, ...args);
208
+ }
209
+ }
@@ -0,0 +1,90 @@
1
+ // All keys are supplied through the existing captcha cache owner. Bound keys share
2
+ // one instance hash tag; image transitions use their original single key.
3
+ export const captchaCasLua = `
4
+ local current = redis.call('GET', KEYS[1])
5
+ if current ~= ARGV[1] then return 0 end
6
+ if ARGV[2] == '' then
7
+ redis.call('DEL', KEYS[1])
8
+ else
9
+ local ttl = tonumber(ARGV[3])
10
+ if ttl > 0 then
11
+ redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl)
12
+ else
13
+ redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL')
14
+ end
15
+ end
16
+ return 1
17
+ `;
18
+
19
+ export const captchaReserveLua = `
20
+ local time = redis.call('TIME')
21
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
22
+ local policy = cjson.decode(ARGV[2])
23
+ local cooldown = tonumber(redis.call('GET', KEYS[3]) or '0')
24
+ local retryAt = cooldown
25
+ local counters = {}
26
+ for i, budget in ipairs(policy.budgets) do
27
+ local value = tonumber(redis.call('GET', KEYS[i + 3]) or '0')
28
+ local deadline = (math.floor(now / budget.windowMs) + 1) * budget.windowMs
29
+ local remaining = redis.call('PTTL', KEYS[i + 3])
30
+ if remaining <= 0 then value = 0 end
31
+ counters[i] = { value = value, deadline = deadline }
32
+ if value >= budget.limit and deadline > retryAt then retryAt = deadline end
33
+ end
34
+ if retryAt > now then return { 0, '', retryAt, cooldown } end
35
+ if redis.call('EXISTS', KEYS[1]) == 1 then return { -1 } end
36
+ local data = cjson.decode(ARGV[3])
37
+ data.bound.expiresAt = now + policy.ttl
38
+ data.bound.resendAt = now + policy.cooldown
39
+ local raw = cjson.encode(data)
40
+ for i, counter in ipairs(counters) do
41
+ redis.call('SET', KEYS[i + 3], tostring(counter.value + 1))
42
+ redis.call('PEXPIREAT', KEYS[i + 3], counter.deadline)
43
+ end
44
+ redis.call('SET', KEYS[1], raw)
45
+ redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
46
+ redis.call('SET', KEYS[2], ARGV[1])
47
+ redis.call('PEXPIREAT', KEYS[2], math.max(data.bound.expiresAt, data.bound.resendAt))
48
+ if policy.cooldown > 0 then
49
+ redis.call('SET', KEYS[3], tostring(data.bound.resendAt))
50
+ redis.call('PEXPIREAT', KEYS[3], data.bound.resendAt)
51
+ end
52
+ return { 1, raw, data.bound.expiresAt, data.bound.resendAt }
53
+ `;
54
+
55
+ export const captchaFinalizeLua = `
56
+ local current = redis.call('GET', KEYS[1])
57
+ if current ~= ARGV[1] or redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
58
+ local data = cjson.decode(current)
59
+ local time = redis.call('TIME')
60
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
61
+ if data.bound.state ~= 'pending' or data.bound.expiresAt <= now then return 0 end
62
+ if ARGV[3] == '' then
63
+ redis.call('DEL', KEYS[1])
64
+ else
65
+ redis.call('SET', KEYS[1], ARGV[3])
66
+ redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
67
+ end
68
+ return 1
69
+ `;
70
+
71
+ // Verification happens in the provider. Only the caller holding this exact active
72
+ // snapshot can commit its verdict, so success cannot race refresh or another use.
73
+ export const captchaConsumeLua = `
74
+ local current = redis.call('GET', KEYS[1])
75
+ if current ~= ARGV[1] then return -1 end
76
+ if redis.call('GET', KEYS[2]) ~= ARGV[2] then return 0 end
77
+ local data = cjson.decode(current)
78
+ local time = redis.call('TIME')
79
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
80
+ if data.bound.state ~= 'active' or data.bound.expiresAt <= now or data.bound.attemptsRemaining <= 0 then
81
+ return 0
82
+ end
83
+ if ARGV[3] == '1' or data.bound.attemptsRemaining == 1 then
84
+ redis.call('DEL', KEYS[1])
85
+ else
86
+ redis.call('SET', KEYS[1], ARGV[4])
87
+ redis.call('PEXPIREAT', KEYS[1], data.bound.expiresAt)
88
+ end
89
+ return 1
90
+ `;
@@ -1,4 +1,4 @@
1
- import type { ICaptchaProviderRecord } from './captchaProvider.ts';
1
+ import type { ICaptchaBinding, ICaptchaProviderRecord } from './captchaProvider.ts';
2
2
  import type { ICaptchaSceneRecord } from './captchaScene.ts';
3
3
  import 'vona';
4
4
 
@@ -7,6 +7,16 @@ export interface ICaptchaDataCache {
7
7
  provider: keyof ICaptchaProviderRecord;
8
8
  token?: unknown;
9
9
  token2?: string;
10
+ revision?: string;
11
+ bound?: {
12
+ binding: ICaptchaBinding;
13
+ scope: string;
14
+ state: 'pending' | 'active';
15
+ expiresAt: number;
16
+ resendAt: number;
17
+ attemptsRemaining: number;
18
+ version: number;
19
+ };
10
20
  }
11
21
 
12
22
  export interface ICaptchaData {
@@ -14,6 +24,10 @@ export interface ICaptchaData {
14
24
  provider: keyof ICaptchaProviderRecord;
15
25
  token?: unknown;
16
26
  payload: unknown;
27
+ status?: 'accepted' | 'rejected' | 'unknown' | 'limited';
28
+ expiresAt?: number;
29
+ resendAt?: number;
30
+ retryAt?: number;
17
31
  }
18
32
 
19
33
  declare module 'vona' {
@@ -1,10 +1,44 @@
1
1
  import type { OmitNever } from 'vona';
2
2
  import type { ServiceOnion, TypeOnionOptionsEnableSimple } from 'vona-module-a-onion';
3
3
 
4
+ import type { ICaptchaSceneRecord } from './captchaScene.ts';
5
+
4
6
  export interface ICaptchaProviderRecord {}
5
7
 
8
+ export interface ICaptchaBinding {
9
+ recipient: string;
10
+ purpose: string;
11
+ subject?: string;
12
+ }
13
+
14
+ export interface ICaptchaLimit {
15
+ limit: number;
16
+ windowMs: number;
17
+ }
18
+
19
+ /** Server-authorized policy, never populated from generic captcha request JSON. */
20
+ export interface ICaptchaAuthorization {
21
+ binding: ICaptchaBinding;
22
+ scope: string;
23
+ ip: string;
24
+ ttl: number;
25
+ cooldown: number;
26
+ maxAttempts: number;
27
+ recipientLimits: ICaptchaLimit[];
28
+ ipLimits: ICaptchaLimit[];
29
+ }
30
+
6
31
  export interface ICaptchaProviderExecute<TOKEN = unknown, PAYLOAD = unknown> {
7
- create(options: IDecoratorCaptchaProviderOptions): Promise<ICaptchaProviderData<TOKEN, PAYLOAD>>;
32
+ readonly bound?: boolean;
33
+ authorize?(
34
+ context: unknown,
35
+ operation: 'issue' | 'consume',
36
+ sceneName: keyof ICaptchaSceneRecord,
37
+ ): ICaptchaAuthorization | undefined | Promise<ICaptchaAuthorization | undefined>;
38
+ create(
39
+ options: IDecoratorCaptchaProviderOptions,
40
+ context?: unknown,
41
+ ): Promise<ICaptchaProviderData<TOKEN, PAYLOAD>>;
8
42
  verify(
9
43
  token: TOKEN,
10
44
  tokenInput: TOKEN,
@@ -15,6 +49,9 @@ export interface ICaptchaProviderExecute<TOKEN = unknown, PAYLOAD = unknown> {
15
49
  export interface ICaptchaProviderData<TOKEN = unknown, PAYLOAD = unknown> {
16
50
  token: TOKEN;
17
51
  payload: PAYLOAD;
52
+ outcome?: 'accepted' | 'rejected' | 'unknown';
53
+ /** Bound providers alone decide whether their actual transport may expose the token. */
54
+ exposeToken?: boolean;
18
55
  }
19
56
 
20
57
  export interface IDecoratorCaptchaProviderOptions extends TypeOnionOptionsEnableSimple {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-captcha",
3
- "version": "5.1.19",
3
+ "version": "5.1.20",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-permission",
3
- "version": "5.1.34",
3
+ "version": "5.1.35",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -1,14 +1,10 @@
1
- import type { VonaContext } from 'vona';
2
1
  import type { ICachingActionKeyInfo } from 'vona-module-a-caching';
3
2
  import type { IRbacPolicyDecision } from 'vona-module-a-rbac';
4
- import type { ContextRoute } from 'vona-module-a-web';
5
3
 
6
4
  import assert from 'node:assert';
7
5
  import { describe, it } from 'node:test';
8
- import { appResource, beanFullNameFromOnionName } from 'vona';
9
- import { app } from 'vona-mock';
6
+ import { beanFullNameFromOnionName } from 'vona';
10
7
  import { getRbacDecision, setRbacDecision, SymbolRbacDecision } from 'vona-module-a-rbac';
11
- import { getCacheControllerRoutes, recordResourceNameToRoutePath } from 'vona-module-a-web';
12
8
 
13
9
  import { BeanPermission } from '../src/bean/bean.permission.ts';
14
10
 
@@ -55,64 +51,6 @@ function createCachePermission(clearedCacheNames: string[]) {
55
51
  return permission;
56
52
  }
57
53
 
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
-
116
54
  describe('permission.test.ts', { concurrency: false }, () => {
117
55
  it('normalizes role cache keys by role identity', () => {
118
56
  const first = createPermission({
@@ -366,159 +304,6 @@ describe('permission.test.ts', { concurrency: false }, () => {
366
304
  assert.equal(ctx.innerAccess, true);
367
305
  });
368
306
 
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
-
522
307
  it('reduces an RBAC action projection to its final boolean result', async () => {
523
308
  const allowed = createPermission({});
524
309
  const denied = createPermission({});
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ratelimit",
3
- "version": "5.1.4",
3
+ "version": "5.1.5",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-suite-a-vona",
3
- "version": "5.1.111",
3
+ "version": "5.1.112",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "author": "",