@prosopo/user-access-policy 3.14.13 → 3.14.15

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.
Files changed (65) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -5
  2. package/.turbo/turbo-build$colon$tsc.log +14 -17
  3. package/.turbo/turbo-build.log +3 -9
  4. package/CHANGELOG.md +36 -0
  5. package/LICENSE +201 -0
  6. package/package.json +83 -81
  7. package/src/.export.ts +0 -65
  8. package/src/api/.export.ts +0 -25
  9. package/src/api/accessRulesApiClient.ts +0 -13
  10. package/src/api/delete/.export.ts +0 -18
  11. package/src/api/delete/deleteAllRules.ts +0 -47
  12. package/src/api/delete/deleteRuleGroups.ts +0 -96
  13. package/src/api/delete/deleteRules.ts +0 -87
  14. package/src/api/read/.export.ts +0 -25
  15. package/src/api/read/fetchRules.ts +0 -96
  16. package/src/api/read/findRuleIds.ts +0 -97
  17. package/src/api/read/getMissingIds.ts +0 -81
  18. package/src/api/ruleApiRoutes.ts +0 -146
  19. package/src/api/rulesApiClient.ts +0 -154
  20. package/src/api/write/.export.ts +0 -15
  21. package/src/api/write/insertRules.ts +0 -185
  22. package/src/api/write/rehashRules.ts +0 -85
  23. package/src/classifyBrowser.ts +0 -77
  24. package/src/classifyOs.ts +0 -74
  25. package/src/headerMatch.ts +0 -180
  26. package/src/matchedRule.ts +0 -99
  27. package/src/mongoose/.export.ts +0 -15
  28. package/src/mongoose/mongooseRuleSchema.ts +0 -75
  29. package/src/redis/.export.ts +0 -17
  30. package/src/redis/reader/redisAggregate.ts +0 -157
  31. package/src/redis/reader/redisRulesQuery.ts +0 -268
  32. package/src/redis/reader/redisRulesReader.ts +0 -738
  33. package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
  34. package/src/redis/redisClient.ts +0 -125
  35. package/src/redis/redisRuleIndex.ts +0 -121
  36. package/src/redis/redisRulesStorage.ts +0 -68
  37. package/src/redis/redisRulesWriter.ts +0 -172
  38. package/src/rule.ts +0 -122
  39. package/src/ruleInput/.export.ts +0 -19
  40. package/src/ruleInput/policyInput.ts +0 -120
  41. package/src/ruleInput/ruleInput.ts +0 -123
  42. package/src/ruleInput/userScopeInput.ts +0 -142
  43. package/src/ruleRecord.ts +0 -77
  44. package/src/rulesStorage.ts +0 -90
  45. package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
  46. package/src/tests/classifyBrowser.unit.test.ts +0 -138
  47. package/src/tests/classifyOs.unit.test.ts +0 -97
  48. package/src/tests/headerMatch.unit.test.ts +0 -350
  49. package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
  50. package/src/tests/matchedRule.unit.test.ts +0 -184
  51. package/src/tests/policyInput.unit.test.ts +0 -218
  52. package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
  53. package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
  54. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
  55. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
  56. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
  57. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
  58. package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
  59. package/src/tests/testLogger.ts +0 -38
  60. package/src/tests/transformRule.unit.test.ts +0 -345
  61. package/src/transformRule.ts +0 -133
  62. package/tsconfig.cjs.json +0 -41
  63. package/tsconfig.json +0 -47
  64. package/tsconfig.tsbuildinfo +0 -1
  65. package/tsconfig.types.json +0 -9
@@ -1,1811 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
16
- import { LogLevel, type Logger, getLogger } from "@prosopo/logger";
17
- import {
18
- type RedisConnection,
19
- createTestRedisConnection,
20
- setupRedisIndex,
21
- } from "@prosopo/redis-client";
22
- import { ResultReason } from "@prosopo/types";
23
- import { randomAsHex } from "@prosopo/util-crypto";
24
- import type { RedisClientType } from "redis";
25
- import {
26
- afterAll,
27
- afterEach,
28
- beforeAll,
29
- beforeEach,
30
- describe,
31
- expect,
32
- test,
33
- } from "vitest";
34
- import { RedisRulesReader } from "#policy/redis/reader/redisRulesReader.js";
35
- import {
36
- ACCESS_RULE_REDIS_KEY_PREFIX,
37
- accessRulesRedisIndex,
38
- getAccessRuleRedisKey,
39
- } from "#policy/redis/redisRuleIndex.js";
40
- import {
41
- RedisRulesWriter,
42
- getRedisRuleValue,
43
- } from "#policy/redis/redisRulesWriter.js";
44
- import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
45
- import {
46
- type AccessRulesReader,
47
- type AccessRulesWriter,
48
- FilterScopeMatch,
49
- } from "#policy/rulesStorage.js";
50
-
51
- describe("redisAccessRulesStorage", () => {
52
- let redisConnection: RedisConnection;
53
- let redisClient: RedisClientType;
54
- let indexName: string;
55
-
56
- // Every member is a no-op sink, except `with`, which has to return a child
57
- // logger — setupRedisIndex calls `logger.with({ name })` and logs through
58
- // the result. Returning the proxy itself keeps the child a sink too.
59
- const mockLogger = new Proxy(
60
- {},
61
- {
62
- get: (_target, property) =>
63
- property === "with" ? () => mockLogger : () => {},
64
- },
65
- ) as unknown as Logger;
66
-
67
- const getUniqueString = () => Math.random().toString(36).substring(2, 15);
68
- const getIndexRecordsCount = async (indexName: string): Promise<number> =>
69
- (await redisClient.ft.info(indexName)).num_docs;
70
-
71
- const insertRules = async (rules: AccessRule[]) => {
72
- const ruleBatches = chunkIntoBatches(rules, 1000);
73
-
74
- await executeBatchesSequentially(ruleBatches, async (batchRules) => {
75
- const queries = redisClient.multi();
76
-
77
- batchRules.map((rule) => {
78
- const ruleKey = getAccessRuleRedisKey(rule);
79
- const ruleValue = getRedisRuleValue(rule);
80
-
81
- queries.hSet(ruleKey, ruleValue);
82
- });
83
-
84
- await queries.exec();
85
- });
86
- };
87
-
88
- beforeAll(async () => {
89
- redisConnection = createTestRedisConnection();
90
-
91
- redisClient = await setupRedisIndex(
92
- redisConnection,
93
- accessRulesRedisIndex,
94
- mockLogger,
95
- ).getClient();
96
- });
97
-
98
- // Move cleanup to afterEach
99
- afterEach(async () => {
100
- if (indexName) {
101
- try {
102
- // Drop index and all documents (DD) created by THIS specific test
103
- await redisClient.ft.dropIndex(indexName, { DD: true });
104
- } catch (e) {
105
- console.error(`Failed to cleanup index ${indexName}`, e);
106
- }
107
- }
108
- }, 120_000);
109
-
110
- beforeEach(async () => {
111
- indexName = randomAsHex(16);
112
-
113
- const result = setupRedisIndex(
114
- redisConnection,
115
- { ...accessRulesRedisIndex, name: indexName },
116
- mockLogger,
117
- );
118
- redisClient = await result.getClient();
119
- });
120
-
121
- describe("writer", () => {
122
- let accessRulesWriter: AccessRulesWriter;
123
-
124
- beforeAll(() => {
125
- accessRulesWriter = new RedisRulesWriter(redisClient, mockLogger);
126
- });
127
-
128
- test("inserts rule", async () => {
129
- const testIndexName = indexName;
130
- // given
131
- const accessRule: AccessRule = {
132
- type: AccessPolicyType.Block,
133
- clientId: "clientId",
134
- };
135
- const accessRuleKey = getAccessRuleRedisKey(accessRule);
136
-
137
- // when
138
- await accessRulesWriter.insertRules([
139
- {
140
- rule: accessRule,
141
- },
142
- ]);
143
-
144
- // then
145
- const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
146
- const indexRecordsCount = await getIndexRecordsCount(testIndexName);
147
-
148
- expect(insertedAccessRule).toEqual(accessRule);
149
- expect(indexRecordsCount).toEqual(1);
150
- });
151
-
152
- test("inserts time limited rule", async () => {
153
- // given
154
- const accessRule: AccessRule = {
155
- type: AccessPolicyType.Block,
156
- clientId: "clientId",
157
- };
158
- const accessRuleKey = getAccessRuleRedisKey(accessRule);
159
- // 1 hour from now.
160
- const expireIn = 60 * 60; // seconds
161
- const expirationTimestamp = new Date(
162
- Date.now() + expireIn * 1000,
163
- ).getTime();
164
- const expirationTimestampInSeconds = Math.floor(
165
- expirationTimestamp / 1000,
166
- );
167
-
168
- // when
169
- await accessRulesWriter.insertRules([
170
- {
171
- rule: accessRule,
172
- expiresUnixTimestamp: expirationTimestampInSeconds,
173
- },
174
- ]);
175
- const ruleKey = getAccessRuleRedisKey(accessRule);
176
- // then
177
- const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
178
- const insertedExpirationResult = await redisClient.expireAt(
179
- ruleKey,
180
- expirationTimestampInSeconds,
181
- );
182
- const indexRecordsCount = await getIndexRecordsCount(indexName);
183
-
184
- const recordExpirySeconds = await redisClient.ttl(ruleKey);
185
-
186
- expect(insertedAccessRule).toEqual(accessRule);
187
- expect(insertedExpirationResult).toBe(1);
188
- expect(recordExpirySeconds).toBeLessThanOrEqual(
189
- expirationTimestampInSeconds,
190
- );
191
-
192
- expect(indexRecordsCount).toBe(1);
193
- });
194
-
195
- // The existing "inserts time limited rule" test above only
196
- // checks that the TTL is *set* on the Redis key. Neither
197
- // side actually verifies the rule stops matching once its
198
- // TTL fires — which is the property operators care about
199
- // (temporary bans should self-clear). Guard against a
200
- // regression that persists rules past their expiry (e.g. a
201
- // future writer that swaps `expireAt` for `persist` by
202
- // accident, or an index rebuild that resurrects expired
203
- // records).
204
- test("expired rule is no longer returned by the reader after its TTL fires", async () => {
205
- const accessRule: AccessRule = {
206
- type: AccessPolicyType.Block,
207
- clientId: `expiring-${getUniqueString()}`,
208
- };
209
- const ruleKey = getAccessRuleRedisKey(accessRule);
210
- // 2 s expiry — long enough that the "before expiry" check
211
- // races the write cleanly, short enough that the test
212
- // completes in a reasonable timeout. Redis TTL granularity
213
- // is 1 s so anything shorter is under-resolution.
214
- const expirationTimestampInSeconds = Math.floor(Date.now() / 1000) + 2;
215
-
216
- await accessRulesWriter.insertRules([
217
- {
218
- rule: accessRule,
219
- expiresUnixTimestamp: expirationTimestampInSeconds,
220
- },
221
- ]);
222
-
223
- // Sanity: the rule exists immediately after write.
224
- const initialRecord = await redisClient.hGetAll(ruleKey);
225
- expect(initialRecord).toEqual(accessRule);
226
-
227
- // Wait past the TTL. Add ~1s slack for Redis's per-key
228
- // expiry sweep — the docs guarantee expired keys stop
229
- // answering reads but the actual key deletion is lazy.
230
- await new Promise((resolve) => setTimeout(resolve, 3500));
231
-
232
- // After expiry: hGetAll returns {} (Redis treats an
233
- // expired key as non-existent for reads).
234
- const expiredRecord = await redisClient.hGetAll(ruleKey);
235
- expect(expiredRecord).toEqual({});
236
-
237
- // And the RediSearch index no longer counts it.
238
- const indexRecordsCount = await getIndexRecordsCount(indexName);
239
- expect(indexRecordsCount).toBe(0);
240
- }, 10_000);
241
-
242
- test("deletes rules", async () => {
243
- // given
244
- const johnAccessRule: AccessRule = {
245
- type: AccessPolicyType.Block,
246
- clientId: getUniqueString(),
247
- };
248
- const johnAccessRuleKey = getAccessRuleRedisKey(johnAccessRule);
249
-
250
- const doeAccessRule: AccessRule = {
251
- type: AccessPolicyType.Block,
252
- clientId: getUniqueString(),
253
- };
254
- const doeAccessRuleKey = getAccessRuleRedisKey(doeAccessRule);
255
-
256
- await insertRules([johnAccessRule, doeAccessRule]);
257
-
258
- // when
259
- await accessRulesWriter.deleteRules([
260
- johnAccessRuleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
261
- ]);
262
-
263
- // then
264
- const presentAccessRule = await redisClient.hGetAll(doeAccessRuleKey);
265
- const indexRecordsCount = await getIndexRecordsCount(indexName);
266
-
267
- expect(presentAccessRule).toEqual(doeAccessRule);
268
- expect(indexRecordsCount).toBe(1);
269
- });
270
-
271
- test("deletes all rules", async () => {
272
- // given
273
- const johnAccessRule: AccessRule = {
274
- type: AccessPolicyType.Block,
275
- clientId: getUniqueString(),
276
- };
277
- const doeAccessRule: AccessRule = {
278
- type: AccessPolicyType.Block,
279
- clientId: getUniqueString(),
280
- };
281
-
282
- await insertRules([johnAccessRule, doeAccessRule]);
283
-
284
- // when
285
- await accessRulesWriter.deleteAllRules();
286
-
287
- // then
288
- const indexRecordsCount = await getIndexRecordsCount(indexName);
289
-
290
- expect(indexRecordsCount).toBe(0);
291
- });
292
-
293
- test("deletes all rules when there are 1 million rules", async () => {
294
- // given
295
- const rulesCount = 1_000_000;
296
- const batchSize = 10_000;
297
- const numBatches = Math.ceil(rulesCount / batchSize);
298
-
299
- // Insert rules in batches to avoid memory exhaustion
300
- // Don't create 1M objects in memory at once!
301
- for (let i = 0; i < numBatches; i++) {
302
- const currentBatchSize = Math.min(
303
- batchSize,
304
- rulesCount - i * batchSize,
305
- );
306
- const batchRules: AccessRule[] = Array.from(
307
- { length: currentBatchSize },
308
- () => ({
309
- type: AccessPolicyType.Block,
310
- clientId: getUniqueString(),
311
- }),
312
- );
313
-
314
- await insertRules(batchRules);
315
- }
316
-
317
- // verify that there are 1 million rules in the database
318
- const beforeDeleteIndexRecordsCount =
319
- await getIndexRecordsCount(indexName);
320
- expect(beforeDeleteIndexRecordsCount).toBe(rulesCount);
321
-
322
- // when
323
- await accessRulesWriter.deleteAllRules();
324
-
325
- // then
326
- const afterDeleteIndexRecordsCount =
327
- await getIndexRecordsCount(indexName);
328
-
329
- expect(afterDeleteIndexRecordsCount).toBe(0);
330
- });
331
- // The trailing 240_000 is the suite timeout. vitest 4 dropped the
332
- // options-object overload for describe, so it has to be a plain number.
333
- }, 240_000);
334
-
335
- describe("reader", () => {
336
- let accessRulesReader: AccessRulesReader;
337
-
338
- beforeAll(async () => {
339
- accessRulesReader = new RedisRulesReader(
340
- redisClient,
341
- getLogger(LogLevel.enum.info, "RedisAccessRulesReader"),
342
- );
343
- });
344
-
345
- test("finds client and global rules by greedy policy scope match", async () => {
346
- // given
347
- const johnId = getUniqueString();
348
- const johnAccessRule: AccessRule = {
349
- type: AccessPolicyType.Block,
350
- clientId: johnId,
351
- };
352
- const doeAccessRule: AccessRule = {
353
- type: AccessPolicyType.Block,
354
- clientId: getUniqueString(),
355
- };
356
- const globalAccessRule: AccessRule = {
357
- type: AccessPolicyType.Block,
358
- };
359
-
360
- await insertRules([johnAccessRule, doeAccessRule, globalAccessRule]);
361
-
362
- // when
363
- const foundByClientAccessRules = await accessRulesReader.findRules({
364
- policyScope: {
365
- clientId: johnId,
366
- },
367
- policyScopeMatch: FilterScopeMatch.Greedy,
368
- });
369
- const foundAccessRules = await accessRulesReader.findRules({
370
- policyScopeMatch: FilterScopeMatch.Greedy,
371
- });
372
-
373
- // then
374
- const indexRecordsCount = await getIndexRecordsCount(indexName);
375
-
376
- expect(indexRecordsCount).toBe(3);
377
- expect(foundByClientAccessRules).toEqual([
378
- johnAccessRule,
379
- globalAccessRule,
380
- ]);
381
- expect(foundAccessRules).toEqual([
382
- johnAccessRule,
383
- doeAccessRule,
384
- globalAccessRule,
385
- ]);
386
- });
387
-
388
- test("finds client or global rules by exact policy scope match", async () => {
389
- // given
390
- const johnId = getUniqueString();
391
-
392
- const johnAccessRule: AccessRule = {
393
- type: AccessPolicyType.Block,
394
- clientId: johnId,
395
- };
396
- const doeAccessRule: AccessRule = {
397
- type: AccessPolicyType.Block,
398
- clientId: getUniqueString(),
399
- };
400
- const globalAccessRule: AccessRule = {
401
- type: AccessPolicyType.Block,
402
- };
403
-
404
- await insertRules([johnAccessRule, doeAccessRule, globalAccessRule]);
405
-
406
- // when
407
- const foundClientAccessRules = await accessRulesReader.findRules({
408
- policyScope: {
409
- clientId: johnId,
410
- },
411
- policyScopeMatch: FilterScopeMatch.Exact,
412
- });
413
- const foundGlobalAccessRules = await accessRulesReader.findRules(
414
- {
415
- policyScopeMatch: FilterScopeMatch.Exact,
416
- },
417
- false,
418
- false,
419
- );
420
-
421
- // then
422
- const indexRecordsCount = await getIndexRecordsCount(indexName);
423
-
424
- expect(indexRecordsCount).toBe(3);
425
- expect(foundClientAccessRules).toEqual([johnAccessRule]);
426
- expect(foundGlobalAccessRules).toEqual([globalAccessRule]);
427
- });
428
-
429
- test("finds rules by greedy user scope match", async () => {
430
- // given
431
-
432
- const johnId = getUniqueString();
433
- const johnIpAccessRule: AccessRule = {
434
- type: AccessPolicyType.Block,
435
- clientId: johnId,
436
- numericIp: BigInt(100),
437
- };
438
- const doeIpAccessRule: AccessRule = {
439
- type: AccessPolicyType.Block,
440
- clientId: getUniqueString(),
441
- numericIp: BigInt(100),
442
- };
443
- const johnHeaderAccessRule: AccessRule = {
444
- type: AccessPolicyType.Block,
445
- headersHash:
446
- "00110110100001111101001101100101101101001000011111010011011001010101000110000111110100110110010111010100100011011101101010000011",
447
- };
448
- const globalJa4AccessRule: AccessRule = {
449
- type: AccessPolicyType.Block,
450
- ja4Hash: "windows",
451
- };
452
-
453
- await insertRules([
454
- johnIpAccessRule,
455
- doeIpAccessRule,
456
- johnHeaderAccessRule,
457
- globalJa4AccessRule,
458
- ]);
459
-
460
- // when
461
- const foundAccessRules = await accessRulesReader.findRules({
462
- policyScope: {
463
- clientId: johnId,
464
- },
465
- userScope: {
466
- numericIp: BigInt(100),
467
- ja4Hash: "windows",
468
- },
469
- userScopeMatch: FilterScopeMatch.Greedy,
470
- });
471
-
472
- // then
473
- const indexRecordsCount = await getIndexRecordsCount(indexName);
474
-
475
- expect(indexRecordsCount).toBe(4);
476
- expect(foundAccessRules).toEqual([johnIpAccessRule, globalJa4AccessRule]);
477
- });
478
-
479
- test("finds rules by exact user scope match", async () => {
480
- // given
481
-
482
- const johnId = getUniqueString();
483
-
484
- const johnTargetAccessRule: AccessRule = {
485
- type: AccessPolicyType.Block,
486
- clientId: johnId,
487
- numericIp: BigInt(100),
488
- ja4Hash: "windows",
489
- };
490
-
491
- const doeTargetAccessRule: AccessRule = {
492
- type: AccessPolicyType.Block,
493
- clientId: getUniqueString(),
494
- numericIp: BigInt(100),
495
- ja4Hash: "windows",
496
- };
497
-
498
- const johnHeaderAccessRule: AccessRule = {
499
- type: AccessPolicyType.Block,
500
- headersHash:
501
- "00110110100001111101001101100101101101001000011111010011011001010101000110000111110100110110010111010100100011011101101010000011",
502
- };
503
-
504
- const globalTargetAccessRule: AccessRule = {
505
- type: AccessPolicyType.Block,
506
- numericIp: BigInt(100),
507
- ja4Hash: "windows",
508
- };
509
-
510
- const globalJa4AccessRule: AccessRule = {
511
- type: AccessPolicyType.Block,
512
- ja4Hash: "windows",
513
- };
514
-
515
- await insertRules([
516
- johnTargetAccessRule,
517
- doeTargetAccessRule,
518
- johnHeaderAccessRule,
519
- globalTargetAccessRule,
520
- globalJa4AccessRule,
521
- ]);
522
-
523
- // when
524
- const foundAccessRules = await accessRulesReader.findRules({
525
- policyScope: {
526
- clientId: johnId,
527
- },
528
- userScope: {
529
- numericIp: BigInt(100),
530
- ja4Hash: "windows",
531
- },
532
- userScopeMatch: FilterScopeMatch.Exact,
533
- });
534
-
535
- // then
536
- const indexRecordsCount = await getIndexRecordsCount(indexName);
537
-
538
- expect(indexRecordsCount).toBe(5);
539
- expect(foundAccessRules).toEqual([
540
- johnTargetAccessRule,
541
- globalTargetAccessRule,
542
- ]);
543
- });
544
-
545
- test("finds rules by greedy ip match", async () => {
546
- // given
547
- const johnId = getUniqueString();
548
-
549
- const johnIpMask_0_100_AccessRule: AccessRule = {
550
- clientId: johnId,
551
- type: AccessPolicyType.Block,
552
- numericIpMaskMin: BigInt(0),
553
- numericIpMaskMax: BigInt(100),
554
- };
555
- const johnIp_100_AccessRule: AccessRule = {
556
- clientId: johnId,
557
- type: AccessPolicyType.Block,
558
- numericIp: BigInt(100),
559
- };
560
- const globalIpMask_100_200_AccessRule: AccessRule = {
561
- type: AccessPolicyType.Block,
562
- numericIpMaskMin: BigInt(100),
563
- numericIpMaskMax: BigInt(200),
564
- };
565
- const doeIpMask_200_300AccessRule: AccessRule = {
566
- clientId: getUniqueString(),
567
- type: AccessPolicyType.Block,
568
- numericIpMaskMin: BigInt(200),
569
- numericIpMaskMax: BigInt(300),
570
- };
571
-
572
- await insertRules([
573
- johnIpMask_0_100_AccessRule,
574
- johnIp_100_AccessRule,
575
- globalIpMask_100_200_AccessRule,
576
- doeIpMask_200_300AccessRule,
577
- ]);
578
-
579
- // when
580
- const ip_0_accessRules = await accessRulesReader.findRules({
581
- policyScope: {
582
- clientId: johnId,
583
- },
584
- userScope: {
585
- numericIp: BigInt(0),
586
- },
587
- userScopeMatch: FilterScopeMatch.Greedy,
588
- });
589
- const ip_99_accessRules = await accessRulesReader.findRules({
590
- policyScope: {
591
- clientId: johnId,
592
- },
593
- userScope: {
594
- numericIp: BigInt(99),
595
- },
596
- userScopeMatch: FilterScopeMatch.Greedy,
597
- });
598
- const ip_100_accessRules = await accessRulesReader.findRules({
599
- policyScope: {
600
- clientId: johnId,
601
- },
602
- userScope: {
603
- numericIp: BigInt(100),
604
- },
605
- userScopeMatch: FilterScopeMatch.Greedy,
606
- });
607
- const ip_101_accessRules = await accessRulesReader.findRules({
608
- policyScope: {
609
- clientId: johnId,
610
- },
611
- userScope: {
612
- numericIp: BigInt(101),
613
- },
614
- userScopeMatch: FilterScopeMatch.Greedy,
615
- });
616
- const ip_201_accessRules = await accessRulesReader.findRules({
617
- policyScope: {
618
- clientId: johnId,
619
- },
620
- userScope: {
621
- numericIp: BigInt(201),
622
- },
623
- userScopeMatch: FilterScopeMatch.Greedy,
624
- });
625
-
626
- // then
627
- const indexRecordsCount = await getIndexRecordsCount(indexName);
628
-
629
- expect(indexRecordsCount).toBe(4);
630
-
631
- expect(ip_0_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
632
- expect(ip_99_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
633
- expect(ip_100_accessRules).toEqual([
634
- johnIpMask_0_100_AccessRule,
635
- johnIp_100_AccessRule,
636
- globalIpMask_100_200_AccessRule,
637
- ]);
638
- expect(ip_101_accessRules).toEqual([globalIpMask_100_200_AccessRule]);
639
- expect(ip_201_accessRules).toEqual([]);
640
- });
641
-
642
- test("finds rules by exact ip match with exact policy match", async () => {
643
- // given
644
- const johnId = getUniqueString();
645
-
646
- const johnIpMask_0_100_AccessRule: AccessRule = {
647
- clientId: johnId,
648
- type: AccessPolicyType.Block,
649
- numericIpMaskMin: BigInt(0),
650
- numericIpMaskMax: BigInt(100),
651
- };
652
- const johnIp_100_AccessRule: AccessRule = {
653
- clientId: johnId,
654
- type: AccessPolicyType.Block,
655
- numericIp: BigInt(100),
656
- };
657
- const globalIpMask_100_200_AccessRule: AccessRule = {
658
- type: AccessPolicyType.Block,
659
- numericIpMaskMin: BigInt(100),
660
- numericIpMaskMax: BigInt(200),
661
- };
662
- const globalIp_100_AccessRule: AccessRule = {
663
- type: AccessPolicyType.Block,
664
- numericIp: BigInt(100),
665
- };
666
-
667
- await insertRules([
668
- johnIpMask_0_100_AccessRule,
669
- johnIp_100_AccessRule,
670
- globalIpMask_100_200_AccessRule,
671
- globalIp_100_AccessRule,
672
- ]);
673
-
674
- // when
675
- const ip_0_accessRules = await accessRulesReader.findRules({
676
- policyScope: {
677
- clientId: johnId,
678
- },
679
- policyScopeMatch: FilterScopeMatch.Exact,
680
- userScope: {
681
- numericIp: BigInt(0),
682
- },
683
- userScopeMatch: FilterScopeMatch.Exact,
684
- });
685
- const ip_100_accessRules = await accessRulesReader.findRules({
686
- policyScope: {
687
- clientId: johnId,
688
- },
689
- policyScopeMatch: FilterScopeMatch.Exact,
690
- userScope: {
691
- numericIp: BigInt(100),
692
- },
693
- userScopeMatch: FilterScopeMatch.Exact,
694
- });
695
-
696
- // then
697
- const indexRecordsCount = await getIndexRecordsCount(indexName);
698
-
699
- expect(indexRecordsCount).toBe(4);
700
-
701
- expect(ip_0_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
702
- expect(ip_100_accessRules).toEqual([
703
- johnIpMask_0_100_AccessRule,
704
- johnIp_100_AccessRule,
705
- ]);
706
- });
707
-
708
- test("finds rules by exact ip match with exact policy match 2", async () => {
709
- // given
710
- const johnId = getUniqueString();
711
- const johnIp = BigInt(100);
712
-
713
- const johnIpMask_0_100_AccessRule: AccessRule = {
714
- clientId: johnId,
715
- type: AccessPolicyType.Block,
716
- numericIpMaskMin: BigInt(0),
717
- numericIpMaskMax: johnIp,
718
- };
719
- const johnIp_100_AccessRule: AccessRule = {
720
- clientId: johnId,
721
- type: AccessPolicyType.Block,
722
- numericIp: johnIp,
723
- };
724
- const globalIpMask_100_200_AccessRule: AccessRule = {
725
- type: AccessPolicyType.Block,
726
- numericIpMaskMin: johnIp,
727
- numericIpMaskMax: BigInt(200),
728
- };
729
- const globalIp_100_AccessRule: AccessRule = {
730
- type: AccessPolicyType.Block,
731
- numericIp: johnIp,
732
- };
733
-
734
- await insertRules([
735
- johnIpMask_0_100_AccessRule,
736
- johnIp_100_AccessRule,
737
- globalIpMask_100_200_AccessRule,
738
- globalIp_100_AccessRule,
739
- ]);
740
-
741
- // when
742
- const ip_0_accessRules = await accessRulesReader.findRules({
743
- policyScope: {
744
- clientId: johnId,
745
- },
746
- policyScopeMatch: FilterScopeMatch.Exact,
747
- userScope: {
748
- numericIp: BigInt(0),
749
- },
750
- userScopeMatch: FilterScopeMatch.Exact,
751
- });
752
- const ip_100_accessRules = await accessRulesReader.findRules({
753
- policyScopeMatch: FilterScopeMatch.Exact,
754
- userScope: {
755
- numericIp: johnIp,
756
- },
757
- userScopeMatch: FilterScopeMatch.Exact,
758
- });
759
-
760
- // then
761
- const indexRecordsCount = await getIndexRecordsCount(indexName);
762
-
763
- expect(indexRecordsCount).toBe(4);
764
-
765
- expect(ip_0_accessRules).toEqual([johnIpMask_0_100_AccessRule]);
766
- expect(ip_100_accessRules).toEqual([
767
- globalIpMask_100_200_AccessRule,
768
- globalIp_100_AccessRule,
769
- ]);
770
- });
771
-
772
- test("does not find rules when some criteria do not match and user scope match is exact", async () => {
773
- // given
774
- const accessRule: AccessRule = {
775
- type: AccessPolicyType.Restrict,
776
- clientId: "clientId",
777
- userAgentHash: "userAgentHash1",
778
- ja4Hash: "ja4Hash",
779
- };
780
-
781
- // when
782
- await insertRules([accessRule]);
783
-
784
- // then
785
- const query = {
786
- policyScope: {
787
- clientId: "clientId",
788
- },
789
- policyScopeMatch: FilterScopeMatch.Exact,
790
- userScope: {
791
- ja4Hash: "ja4Hash",
792
- userAgentHash: "userAgentHash2",
793
- },
794
- userScopeMatch: FilterScopeMatch.Exact,
795
- };
796
-
797
- const foundAccessRules = await accessRulesReader.findRules(query);
798
- expect(foundAccessRules).toEqual([]);
799
- });
800
-
801
- test("does not find rules when query is more exact than rule and user scope match is exact", async () => {
802
- // given
803
- const accessRule: AccessRule = {
804
- type: AccessPolicyType.Restrict,
805
- clientId: "clientId",
806
- ja4Hash: "ja4Hash",
807
- };
808
-
809
- // when
810
- await insertRules([accessRule]);
811
-
812
- // then
813
- const query = {
814
- policyScope: {
815
- clientId: "clientId",
816
- },
817
- policyScopeMatch: FilterScopeMatch.Exact,
818
- userScope: {
819
- ja4Hash: "ja4Hash",
820
- userAgentHash: "userAgentHash",
821
- },
822
- userScopeMatch: FilterScopeMatch.Exact,
823
- };
824
-
825
- const foundAccessRules = await accessRulesReader.findRules(query);
826
- expect(foundAccessRules).toEqual([]);
827
- });
828
-
829
- test("does find rules when query is more exact than rule and user scope match is greedy", async () => {
830
- // given
831
- const accessRule: AccessRule = {
832
- type: AccessPolicyType.Restrict,
833
- clientId: "clientId",
834
- ja4Hash: "ja4Hash",
835
- };
836
-
837
- // when
838
- await insertRules([accessRule]);
839
-
840
- // then
841
- const query = {
842
- policyScope: {
843
- clientId: "clientId",
844
- },
845
- policyScopeMatch: FilterScopeMatch.Exact,
846
- userScope: {
847
- ja4Hash: "ja4Hash",
848
- userAgentHash: "userAgentHash",
849
- },
850
- userScopeMatch: FilterScopeMatch.Greedy,
851
- };
852
-
853
- const foundAccessRules = await accessRulesReader.findRules(query);
854
- expect(foundAccessRules).toEqual([accessRule]);
855
- });
856
-
857
- test("does find rules when some criteria do not match and user scope match is exact", async () => {
858
- // given
859
- const accessRule: AccessRule = {
860
- type: AccessPolicyType.Restrict,
861
- clientId: "clientId",
862
- userAgentHash: "userAgentHash",
863
- ja4Hash: "ja4Hash",
864
- };
865
-
866
- // when
867
- getAccessRuleRedisKey(accessRule);
868
- await insertRules([accessRule]);
869
-
870
- // then
871
- const query = {
872
- policyScope: {
873
- clientId: "clientId",
874
- },
875
- policyScopeMatch: FilterScopeMatch.Exact,
876
- userScope: {
877
- ja4Hash: "ja4Hash",
878
- userAgentHash: "userAgentHash",
879
- },
880
- userScopeMatch: FilterScopeMatch.Greedy,
881
- };
882
-
883
- const foundAccessRules = await accessRulesReader.findRules(query);
884
- expect(foundAccessRules).toEqual([accessRule]);
885
- });
886
-
887
- test("if an ip rule and an ip mask rule is applied for a single IP at client level, both rules are returned with the more specific IP rule being first in the list", async () => {
888
- // given
889
- const accessRule: AccessRule = {
890
- type: AccessPolicyType.Restrict,
891
- clientId: "clientId",
892
- numericIp: BigInt(10),
893
- };
894
- const ipRangeAccessRule: AccessRule = {
895
- type: AccessPolicyType.Restrict,
896
- clientId: "clientId",
897
- numericIpMaskMin: BigInt(10),
898
- numericIpMaskMax: BigInt(20),
899
- };
900
-
901
- // when
902
- await insertRules([accessRule, ipRangeAccessRule]);
903
-
904
- // then
905
-
906
- const query = {
907
- policyScope: {
908
- clientId: "clientId",
909
- },
910
- policyScopeMatch: FilterScopeMatch.Exact,
911
- userScope: {
912
- numericIp: BigInt(10),
913
- },
914
- userScopeMatch: FilterScopeMatch.Exact,
915
- };
916
-
917
- const foundAccessRules = await accessRulesReader.findRules(query);
918
- expect(foundAccessRules).toEqual([accessRule, ipRangeAccessRule]);
919
- });
920
-
921
- test("finds rules by headHash with exact match", async () => {
922
- // given
923
- const headHash1 = "abc123def456";
924
- const headHash2 = "xyz789ghi012";
925
-
926
- const headHashAccessRule: AccessRule = {
927
- type: AccessPolicyType.Block,
928
- clientId: "clientId",
929
- headHash: headHash1,
930
- };
931
- const otherHeadHashAccessRule: AccessRule = {
932
- type: AccessPolicyType.Block,
933
- clientId: "clientId",
934
- headHash: headHash2,
935
- };
936
-
937
- await insertRules([headHashAccessRule, otherHeadHashAccessRule]);
938
-
939
- // when
940
- const foundAccessRules = await accessRulesReader.findRules({
941
- policyScope: {
942
- clientId: "clientId",
943
- },
944
- policyScopeMatch: FilterScopeMatch.Exact,
945
- userScope: {
946
- headHash: headHash1,
947
- },
948
- userScopeMatch: FilterScopeMatch.Exact,
949
- });
950
-
951
- // then
952
- expect(foundAccessRules).toEqual([headHashAccessRule]);
953
- });
954
-
955
- test("finds rules by coords with exact match", async () => {
956
- // given
957
- const coords1 = "[[[100,200]]]";
958
- const coords2 = "[[[300,400]]]";
959
-
960
- const coordsAccessRule: AccessRule = {
961
- type: AccessPolicyType.Block,
962
- clientId: "clientId",
963
- coords: coords1,
964
- };
965
- const otherCoordsAccessRule: AccessRule = {
966
- type: AccessPolicyType.Block,
967
- clientId: "clientId",
968
- coords: coords2,
969
- };
970
-
971
- await insertRules([coordsAccessRule, otherCoordsAccessRule]);
972
-
973
- // when
974
- const foundAccessRules = await accessRulesReader.findRules({
975
- policyScope: {
976
- clientId: "clientId",
977
- },
978
- policyScopeMatch: FilterScopeMatch.Exact,
979
- userScope: {
980
- coords: coords1,
981
- },
982
- userScopeMatch: FilterScopeMatch.Exact,
983
- });
984
-
985
- // then
986
- expect(foundAccessRules).toEqual([coordsAccessRule]);
987
- });
988
-
989
- test("finds rules by combined headHash and coords with exact match", async () => {
990
- // given
991
- const headHash1 = "abc123def456";
992
- const coords1 = "[[[100,200]]]";
993
-
994
- const combinedAccessRule: AccessRule = {
995
- type: AccessPolicyType.Block,
996
- clientId: "clientId",
997
- headHash: headHash1,
998
- coords: coords1,
999
- };
1000
- const headHashOnlyAccessRule: AccessRule = {
1001
- type: AccessPolicyType.Restrict,
1002
- clientId: "clientId",
1003
- headHash: headHash1,
1004
- };
1005
-
1006
- await insertRules([combinedAccessRule, headHashOnlyAccessRule]);
1007
-
1008
- // when
1009
- const foundAccessRules = await accessRulesReader.findRules({
1010
- policyScope: {
1011
- clientId: "clientId",
1012
- },
1013
- policyScopeMatch: FilterScopeMatch.Exact,
1014
- userScope: {
1015
- headHash: headHash1,
1016
- coords: coords1,
1017
- },
1018
- userScopeMatch: FilterScopeMatch.Exact,
1019
- });
1020
-
1021
- // then
1022
- expect(foundAccessRules).toEqual([combinedAccessRule]);
1023
- });
1024
-
1025
- test("finds rules by countryCode with exact match", async () => {
1026
- // given
1027
- const countryCode1 = "US";
1028
- const countryCode2 = "GB";
1029
-
1030
- const countryCodeAccessRule: AccessRule = {
1031
- type: AccessPolicyType.Block,
1032
- clientId: "clientId",
1033
- countryCode: countryCode1,
1034
- };
1035
- const otherCountryCodeAccessRule: AccessRule = {
1036
- type: AccessPolicyType.Block,
1037
- clientId: "clientId",
1038
- countryCode: countryCode2,
1039
- };
1040
-
1041
- await insertRules([countryCodeAccessRule, otherCountryCodeAccessRule]);
1042
-
1043
- // when
1044
- const foundAccessRules = await accessRulesReader.findRules({
1045
- policyScope: {
1046
- clientId: "clientId",
1047
- },
1048
- policyScopeMatch: FilterScopeMatch.Exact,
1049
- userScope: {
1050
- countryCode: countryCode1,
1051
- },
1052
- userScopeMatch: FilterScopeMatch.Exact,
1053
- });
1054
-
1055
- // then
1056
- expect(foundAccessRules).toEqual([countryCodeAccessRule]);
1057
- });
1058
-
1059
- test("finds rules by combined countryCode and userId with exact match", async () => {
1060
- // given
1061
- const countryCode1 = "US";
1062
- const userId1 = "user123";
1063
-
1064
- const combinedAccessRule: AccessRule = {
1065
- type: AccessPolicyType.Block,
1066
- clientId: "clientId",
1067
- countryCode: countryCode1,
1068
- userId: userId1,
1069
- };
1070
- const countryCodeOnlyAccessRule: AccessRule = {
1071
- type: AccessPolicyType.Restrict,
1072
- clientId: "clientId",
1073
- countryCode: countryCode1,
1074
- };
1075
-
1076
- await insertRules([combinedAccessRule, countryCodeOnlyAccessRule]);
1077
-
1078
- // when
1079
- const foundAccessRules = await accessRulesReader.findRules({
1080
- policyScope: {
1081
- clientId: "clientId",
1082
- },
1083
- policyScopeMatch: FilterScopeMatch.Exact,
1084
- userScope: {
1085
- countryCode: countryCode1,
1086
- userId: userId1,
1087
- },
1088
- userScopeMatch: FilterScopeMatch.Exact,
1089
- });
1090
-
1091
- // then
1092
- expect(foundAccessRules).toEqual([combinedAccessRule]);
1093
- });
1094
-
1095
- test("returns remaining rules when a matched document's hash key has been deleted", async () => {
1096
- // given
1097
- const clientId = getUniqueString();
1098
-
1099
- const survivingRule: AccessRule = {
1100
- type: AccessPolicyType.Block,
1101
- clientId: clientId,
1102
- userId: "user1",
1103
- };
1104
- const deletedRule: AccessRule = {
1105
- type: AccessPolicyType.Block,
1106
- clientId: clientId,
1107
- userId: "user2",
1108
- };
1109
-
1110
- await insertRules([survivingRule, deletedRule]);
1111
-
1112
- // Delete the hash key for deletedRule directly, bypassing the writer.
1113
- // The index may still reference it briefly, simulating the race
1114
- // condition that caused the @redis/search documentValue(null) crash.
1115
- const deletedRuleKey = getAccessRuleRedisKey(deletedRule);
1116
- await redisClient.del(deletedRuleKey);
1117
-
1118
- // when
1119
- const foundAccessRules = await accessRulesReader.findRules({
1120
- policyScope: {
1121
- clientId: clientId,
1122
- },
1123
- policyScopeMatch: FilterScopeMatch.Exact,
1124
- userScope: {
1125
- userId: "user1",
1126
- },
1127
- userScopeMatch: FilterScopeMatch.Greedy,
1128
- });
1129
-
1130
- // then - should not throw and should return the surviving rule
1131
- expect(foundAccessRules).toEqual([survivingRule]);
1132
- });
1133
-
1134
- test("returns empty array without throwing when all matched documents have been deleted", async () => {
1135
- // given
1136
- const clientId = getUniqueString();
1137
-
1138
- const rule: AccessRule = {
1139
- type: AccessPolicyType.Block,
1140
- clientId: clientId,
1141
- userId: "user1",
1142
- };
1143
-
1144
- await insertRules([rule]);
1145
-
1146
- // Delete the hash key directly
1147
- const ruleKey = getAccessRuleRedisKey(rule);
1148
- await redisClient.del(ruleKey);
1149
-
1150
- // when
1151
- const foundAccessRules = await accessRulesReader.findRules({
1152
- policyScope: {
1153
- clientId: clientId,
1154
- },
1155
- policyScopeMatch: FilterScopeMatch.Exact,
1156
- userScope: {
1157
- userId: "user1",
1158
- },
1159
- userScopeMatch: FilterScopeMatch.Greedy,
1160
- });
1161
-
1162
- // then - should not throw, should return empty array
1163
- expect(foundAccessRules).toEqual([]);
1164
- });
1165
-
1166
- test("returns all matches when the candidate set exceeds the FT.SEARCH page size", async () => {
1167
- // Regression: under the production traffic profile of a high-volume
1168
- // bot attack, the greedy `@field:{X} | @field:{Y}` query returns
1169
- // thousands of candidate rules sharing the dominant ja4 fingerprint.
1170
- // FT.SEARCH's LIMIT (1000) silently truncated the candidate set,
1171
- // dropping less-frequent block rules — they never reached the
1172
- // JS-side specificity sort, so verify let the bot through.
1173
- // FT.AGGREGATE WITHCURSOR paginates the result and returns all of
1174
- // them. This test inserts > 1000 rules so the old code would
1175
- // truncate; the target block rule must still come back.
1176
- const clientId = getUniqueString();
1177
- const popularJa4 = `t13d1516h2_${getUniqueString()}`;
1178
-
1179
- const targetBlockRule: AccessRule = {
1180
- type: AccessPolicyType.Block,
1181
- clientId: clientId,
1182
- ja4Hash: popularJa4,
1183
- coords: "[[[867,60]]]",
1184
- };
1185
-
1186
- // 1500 noise rules sharing the popular ja4 but with distinct coords.
1187
- // 1500 > REDIS_BATCH_SIZE (1000) — guarantees the targetBlockRule's
1188
- // FT index position has at least a 1/3 chance of sitting past the
1189
- // truncation boundary on any given run. With pagination, it must
1190
- // always come back regardless of indexed order.
1191
- const noiseRules: AccessRule[] = Array.from({ length: 1500 }, (_, i) => ({
1192
- type: AccessPolicyType.Restrict,
1193
- clientId: clientId,
1194
- ja4Hash: popularJa4,
1195
- coords: `[[[${i % 1024},${60 + (i % 32)}]]]`,
1196
- description: `noise-${i}`,
1197
- }));
1198
-
1199
- await insertRules([targetBlockRule, ...noiseRules]);
1200
-
1201
- const indexRecordsCount = await getIndexRecordsCount(indexName);
1202
- expect(indexRecordsCount).toBe(1501);
1203
-
1204
- const found = await accessRulesReader.findRules({
1205
- policyScope: { clientId: clientId },
1206
- policyScopeMatch: FilterScopeMatch.Greedy,
1207
- userScope: {
1208
- ja4Hash: popularJa4,
1209
- coords: "[[[867,60]]]",
1210
- },
1211
- userScopeMatch: FilterScopeMatch.Greedy,
1212
- });
1213
-
1214
- expect(found.length).toBe(1501);
1215
- expect(found).toContainEqual(targetBlockRule);
1216
- }, 60_000);
1217
-
1218
- test("keeps a Block rule's messageKey through the request-time block lookup", async () => {
1219
- const clientId = getUniqueString();
1220
- const userId = getUniqueString();
1221
- const accessRule: AccessRule = {
1222
- type: AccessPolicyType.Block,
1223
- clientId,
1224
- userId,
1225
- messageKey: ResultReason.TOO_MANY_LOCALHOST,
1226
- };
1227
-
1228
- await insertRules([accessRule]);
1229
-
1230
- const foundAccessRules = await accessRulesReader.findRules(
1231
- {
1232
- policyScope: { clientId },
1233
- policyScopeMatch: FilterScopeMatch.Exact,
1234
- userScope: { userId },
1235
- userScopeMatch: FilterScopeMatch.Exact,
1236
- blockOnly: true,
1237
- },
1238
- true,
1239
- );
1240
-
1241
- expect(foundAccessRules).toEqual([accessRule]);
1242
- });
1243
-
1244
- test("finds rules with matchingFieldsOnly when only userId is set and all IP fields are missing", async () => {
1245
- // This is the exact scenario from the production error where the query
1246
- // contained duplicate ismissing(@numericIpMaskMin) ismissing(@numericIpMaskMax)
1247
- // given
1248
- const clientId = getUniqueString();
1249
- const userId = getUniqueString();
1250
-
1251
- const accessRule: AccessRule = {
1252
- type: AccessPolicyType.Block,
1253
- clientId: clientId,
1254
- userId: userId,
1255
- };
1256
-
1257
- await insertRules([accessRule]);
1258
-
1259
- // when - matchingFieldsOnly=true adds ismissing for all schema fields not in the scope
1260
- const foundAccessRules = await accessRulesReader.findRules(
1261
- {
1262
- policyScope: {
1263
- clientId: clientId,
1264
- },
1265
- policyScopeMatch: FilterScopeMatch.Exact,
1266
- userScope: {
1267
- userId: userId,
1268
- },
1269
- userScopeMatch: FilterScopeMatch.Exact,
1270
- },
1271
- true,
1272
- );
1273
-
1274
- // then
1275
- expect(foundAccessRules).toEqual([accessRule]);
1276
- });
1277
-
1278
- test("findRulesRanked does not throw when a matched candidate is missing @type", async () => {
1279
- // Production repro: under 3.6.40 we observed
1280
- // "Could not find the value for a parameter name, consider
1281
- // using EXISTS if applicable for type"
1282
- // from the FT.AGGREGATE pipeline in findRulesRanked. The cause
1283
- // is SEVERITY_EXPR = `(@type == "block")` — a bare @type
1284
- // reference that fails when a candidate document lacks the
1285
- // `type` field. Candidates can reach the pipeline without
1286
- // `type` two ways: (a) a partial-write / rehash race in the
1287
- // writer, (b) a stale RediSearch index entry pointing at a
1288
- // hash whose `type` field has been HDEL'd. Either way the
1289
- // whole aggregate fails and the catch in findRulesRanked
1290
- // returns [], i.e. NO rules match — a Block rule that should
1291
- // fire silently lets the request through.
1292
- const clientId = getUniqueString();
1293
- const userId = getUniqueString();
1294
-
1295
- const accessRule: AccessRule = {
1296
- type: AccessPolicyType.Block,
1297
- clientId: clientId,
1298
- userId: userId,
1299
- };
1300
-
1301
- await insertRules([accessRule]);
1302
-
1303
- // Simulate the malformed-doc scenario by removing the `type`
1304
- // field from the hash. The RediSearch index still references
1305
- // the doc (type is not part of the index schema) but the
1306
- // APPLY LOAD will pull an undefined @type.
1307
- const ruleKey = getAccessRuleRedisKey(accessRule);
1308
- await redisClient.hDel(ruleKey, "type");
1309
-
1310
- // when - matchingFieldsOnly=true routes through findRulesRanked
1311
- const foundAccessRules = await accessRulesReader.findRules(
1312
- {
1313
- policyScope: { clientId: clientId },
1314
- policyScopeMatch: FilterScopeMatch.Exact,
1315
- userScope: { userId: userId },
1316
- userScopeMatch: FilterScopeMatch.Exact,
1317
- },
1318
- true,
1319
- );
1320
-
1321
- // then - the typeless doc must not crash the pipeline.
1322
- // `type` is required to reconstruct an AccessRule, so the
1323
- // doc is dropped from the result rather than returned with
1324
- // a default. The remaining valid rules (none here) come back.
1325
- expect(foundAccessRules).toEqual([]);
1326
- });
1327
-
1328
- test("findRulesRanked returns the valid rule and skips a typeless ghost candidate", async () => {
1329
- // Same scenario as the previous test but with a co-resident
1330
- // valid rule. The typeless ghost must not poison the
1331
- // pipeline — the valid rule still has to come back so the
1332
- // block decision sticks.
1333
- const clientId = getUniqueString();
1334
- const userId = getUniqueString();
1335
-
1336
- const validRule: AccessRule = {
1337
- type: AccessPolicyType.Block,
1338
- clientId: clientId,
1339
- userId: userId,
1340
- ja4Hash: "t13d1313h2_valid",
1341
- };
1342
- const ghostRule: AccessRule = {
1343
- type: AccessPolicyType.Block,
1344
- clientId: clientId,
1345
- userId: userId,
1346
- ja4Hash: "t13d1313h2_ghost",
1347
- };
1348
-
1349
- await insertRules([validRule, ghostRule]);
1350
-
1351
- // Strip `type` from the ghost only.
1352
- const ghostKey = getAccessRuleRedisKey(ghostRule);
1353
- await redisClient.hDel(ghostKey, "type");
1354
-
1355
- const foundAccessRules = await accessRulesReader.findRules(
1356
- {
1357
- policyScope: { clientId: clientId },
1358
- policyScopeMatch: FilterScopeMatch.Exact,
1359
- userScope: { userId: userId },
1360
- userScopeMatch: FilterScopeMatch.Greedy,
1361
- },
1362
- true,
1363
- );
1364
-
1365
- expect(foundAccessRules).toEqual([validRule]);
1366
- });
1367
-
1368
- test("findRulesRanked preserves bigint precision for IPv6 numericIp values", async () => {
1369
- // Production repro: under 3.6.40.1 we saw
1370
- // "Cannot convert 5.59112965392e+37 to a BigInt"
1371
- // from the FT.AGGREGATE path in findRulesRanked whenever a
1372
- // matched candidate carries an IPv6 numericIp. The cause is
1373
- // that RediSearch indexes NUMERIC fields as 8-byte doubles
1374
- // and FT.AGGREGATE LOAD reads from that index buffer (not
1375
- // the underlying hash), so any value past
1376
- // Number.MAX_SAFE_INTEGER round-trips as scientific
1377
- // notation. `z.coerce.bigint()` then throws and the whole
1378
- // aggregate gets caught + returned as []. The aggregate is
1379
- // now used purely as a ranker over @__key; the field values
1380
- // come back via HGETALL, which preserves the original
1381
- // 38-digit string stored in the hash. This test inserts a
1382
- // rule with the same shape as the failing prod query — an
1383
- // IPv6 numericIp + matching userScope — and asserts the
1384
- // rule is returned with the bigint intact.
1385
- const clientId = getUniqueString();
1386
- const userId = getUniqueString();
1387
- // 38-digit IPv6 value past Number.MAX_SAFE_INTEGER. Anything
1388
- // > 2**53 demonstrates the precision loss; this specific
1389
- // value matches the order of magnitude of the prod error.
1390
- const ipv6NumericIp = 55878094658432211238406371356040233102n;
1391
-
1392
- const accessRule: AccessRule = {
1393
- type: AccessPolicyType.Block,
1394
- clientId: clientId,
1395
- userId: userId,
1396
- numericIp: ipv6NumericIp,
1397
- };
1398
-
1399
- await insertRules([accessRule]);
1400
-
1401
- const foundAccessRules = await accessRulesReader.findRules(
1402
- {
1403
- policyScope: { clientId: clientId },
1404
- policyScopeMatch: FilterScopeMatch.Exact,
1405
- userScope: { userId: userId, numericIp: ipv6NumericIp },
1406
- userScopeMatch: FilterScopeMatch.Greedy,
1407
- },
1408
- true,
1409
- );
1410
-
1411
- expect(foundAccessRules).toEqual([accessRule]);
1412
- expect(foundAccessRules[0]?.numericIp).toBe(ipv6NumericIp);
1413
- });
1414
-
1415
- test("findRulesRanked preserves rank order over multiple candidates", async () => {
1416
- // Assumption under test: the FT.AGGREGATE -> HGETALL refactor
1417
- // preserves the rank order produced by SORTBY @_rank DESC.
1418
- // reply.results is rank-sorted; the for-of loop pushes
1419
- // __key in iteration order; multi.exec() returns pipeline
1420
- // results in queue order; the non-empty filter is stable;
1421
- // parseRedisRecords' flatMap is stable. Verified end-to-end
1422
- // here by inserting two rules whose specificity differs by
1423
- // exactly one extra populated field, and asserting the more
1424
- // specific rule comes back at index [0].
1425
- const clientId = getUniqueString();
1426
- const userId = getUniqueString();
1427
- const ja4 = `t13d1313h2_${getUniqueString()}`;
1428
-
1429
- // 4 populated scalar fields ⇒ _spec = 4
1430
- const moreSpecificRule: AccessRule = {
1431
- type: AccessPolicyType.Restrict,
1432
- clientId: clientId,
1433
- userId: userId,
1434
- ja4Hash: ja4,
1435
- countryCode: "GB",
1436
- };
1437
- // 2 populated scalar fields ⇒ _spec = 2
1438
- const lessSpecificRule: AccessRule = {
1439
- type: AccessPolicyType.Restrict,
1440
- clientId: clientId,
1441
- ja4Hash: ja4,
1442
- };
1443
-
1444
- // Insert deliberately in the LEAST-specific-first order so
1445
- // that any accidental "preserve insertion order" code path
1446
- // (e.g. forgetting SORTBY entirely) would produce
1447
- // [lessSpecific, moreSpecific] and the assertion would
1448
- // catch it.
1449
- await insertRules([lessSpecificRule, moreSpecificRule]);
1450
-
1451
- const foundAccessRules = await accessRulesReader.findRules(
1452
- {
1453
- policyScope: { clientId: clientId },
1454
- policyScopeMatch: FilterScopeMatch.Exact,
1455
- userScope: {
1456
- userId: userId,
1457
- ja4Hash: ja4,
1458
- countryCode: "GB",
1459
- },
1460
- userScopeMatch: FilterScopeMatch.Greedy,
1461
- },
1462
- true,
1463
- );
1464
-
1465
- expect(foundAccessRules).toHaveLength(2);
1466
- expect(foundAccessRules[0]).toEqual(moreSpecificRule);
1467
- expect(foundAccessRules[1]).toEqual(lessSpecificRule);
1468
- });
1469
-
1470
- test("findRulesRanked ranks Block over Restrict at equal specificity", async () => {
1471
- // Assumption under test: SEVERITY_EXPR contributes its
1472
- // weight correctly through the HGETALL retrieval — Block
1473
- // rules tied on specificity with Restrict rules still come
1474
- // back first. RANK_EXPR = (_spec * 2) + _sev so at equal
1475
- // spec the Block (_sev = 1) outranks the Restrict (_sev = 0)
1476
- // by 1 point.
1477
- const clientId = getUniqueString();
1478
- const userId = getUniqueString();
1479
-
1480
- const blockRule: AccessRule = {
1481
- type: AccessPolicyType.Block,
1482
- clientId: clientId,
1483
- userId: userId,
1484
- };
1485
- const restrictRule: AccessRule = {
1486
- type: AccessPolicyType.Restrict,
1487
- clientId: clientId,
1488
- userId: userId,
1489
- description: "tiebreaker-restrict",
1490
- };
1491
-
1492
- // Insert restrict first; if SEVERITY tiebreaker is dropped
1493
- // silently the result would lead with the restrict.
1494
- await insertRules([restrictRule, blockRule]);
1495
-
1496
- const foundAccessRules = await accessRulesReader.findRules(
1497
- {
1498
- policyScope: { clientId: clientId },
1499
- policyScopeMatch: FilterScopeMatch.Exact,
1500
- userScope: { userId: userId },
1501
- userScopeMatch: FilterScopeMatch.Greedy,
1502
- },
1503
- true,
1504
- );
1505
-
1506
- expect(foundAccessRules).toHaveLength(2);
1507
- expect(foundAccessRules[0]?.type).toBe(AccessPolicyType.Block);
1508
- expect(foundAccessRules[1]?.type).toBe(AccessPolicyType.Restrict);
1509
- });
1510
-
1511
- test("findRulesRanked: keys DEL'd between aggregate and HGETALL drop out without reordering survivors", async () => {
1512
- // Assumption under test: the race window between
1513
- // FT.AGGREGATE and the HGETALL fanout is handled by the
1514
- // non-empty filter, AND the surviving entries keep their
1515
- // relative rank order (Array.filter is stable). Mirrors the
1516
- // existing greedy-path "returns remaining rules when a
1517
- // matched document's hash key has been deleted" test but
1518
- // asserts the matchingFieldsOnly=true (ranked) path.
1519
- const clientId = getUniqueString();
1520
- const userId = getUniqueString();
1521
-
1522
- // 3 specificity levels: the middle one will be DEL'd
1523
- // mid-pipeline. The other two must still come back in the
1524
- // correct rank order.
1525
- const topRule: AccessRule = {
1526
- type: AccessPolicyType.Block,
1527
- clientId: clientId,
1528
- userId: userId,
1529
- ja4Hash: "t13d_top",
1530
- countryCode: "GB",
1531
- };
1532
- const middleRule: AccessRule = {
1533
- type: AccessPolicyType.Restrict,
1534
- clientId: clientId,
1535
- userId: userId,
1536
- ja4Hash: "t13d_mid",
1537
- };
1538
- const bottomRule: AccessRule = {
1539
- type: AccessPolicyType.Restrict,
1540
- clientId: clientId,
1541
- userId: userId,
1542
- };
1543
-
1544
- await insertRules([topRule, middleRule, bottomRule]);
1545
-
1546
- // Drop the middle rule's hash directly. The RediSearch
1547
- // index still references it for a window; FT.AGGREGATE
1548
- // returns 3 keys, HGETALL returns one empty + two
1549
- // populated. The filter must drop the empty one and the
1550
- // remaining two must stay in the {top, bottom} order.
1551
- const middleKey = getAccessRuleRedisKey(middleRule);
1552
- await redisClient.del(middleKey);
1553
-
1554
- const foundAccessRules = await accessRulesReader.findRules(
1555
- {
1556
- policyScope: { clientId: clientId },
1557
- policyScopeMatch: FilterScopeMatch.Exact,
1558
- userScope: {
1559
- userId: userId,
1560
- ja4Hash: "t13d_top",
1561
- countryCode: "GB",
1562
- },
1563
- userScopeMatch: FilterScopeMatch.Greedy,
1564
- },
1565
- true,
1566
- );
1567
-
1568
- expect(foundAccessRules).toHaveLength(2);
1569
- expect(foundAccessRules[0]).toEqual(topRule);
1570
- expect(foundAccessRules[1]).toEqual(bottomRule);
1571
- });
1572
-
1573
- test("blockOnly filter returns only Block rules even when Restrict rules share the same user scope", async () => {
1574
- // Mix the two policy types across the same (clientId, ja4Hash)
1575
- // space so the strict-match query would otherwise return both.
1576
- // Without blockOnly, the result includes Restrict rules; with
1577
- // blockOnly the Redis-side `@type:{block}` clause excludes
1578
- // them before the JS sees the candidates.
1579
- const clientId = getUniqueString();
1580
- const ja4Hash = "t13d_blockonly";
1581
-
1582
- const block1: AccessRule = {
1583
- type: AccessPolicyType.Block,
1584
- clientId,
1585
- ja4Hash,
1586
- };
1587
- const block2: AccessRule = {
1588
- type: AccessPolicyType.Block,
1589
- clientId,
1590
- ja4Hash,
1591
- coords: "[[[1,2]]]",
1592
- };
1593
- const restrict1: AccessRule = {
1594
- type: AccessPolicyType.Restrict,
1595
- clientId,
1596
- ja4Hash,
1597
- };
1598
- const restrict2: AccessRule = {
1599
- type: AccessPolicyType.Restrict,
1600
- clientId,
1601
- ja4Hash,
1602
- coords: "[[[3,4]]]",
1603
- };
1604
- const blockOtherClient: AccessRule = {
1605
- type: AccessPolicyType.Block,
1606
- clientId: getUniqueString(),
1607
- ja4Hash,
1608
- };
1609
-
1610
- await insertRules([
1611
- block1,
1612
- block2,
1613
- restrict1,
1614
- restrict2,
1615
- blockOtherClient,
1616
- ]);
1617
-
1618
- // Greedy / no blockOnly → both Block and Restrict for this
1619
- // clientId come back. The other-client rule is excluded by
1620
- // the greedy policy scope match (different clientId AND set).
1621
- const mixed = await accessRulesReader.findRules({
1622
- policyScope: { clientId },
1623
- policyScopeMatch: FilterScopeMatch.Greedy,
1624
- userScope: { ja4Hash },
1625
- userScopeMatch: FilterScopeMatch.Greedy,
1626
- });
1627
- const mixedTypes = mixed.map((r) => r.type).sort();
1628
- expect(mixed).toHaveLength(4);
1629
- expect(mixedTypes).toEqual([
1630
- AccessPolicyType.Block,
1631
- AccessPolicyType.Block,
1632
- AccessPolicyType.Restrict,
1633
- AccessPolicyType.Restrict,
1634
- ]);
1635
-
1636
- // Same query with blockOnly → only the two Block rules for
1637
- // this clientId. Restrict rules must be filtered server-side.
1638
- const blockOnly = await accessRulesReader.findRules({
1639
- policyScope: { clientId },
1640
- policyScopeMatch: FilterScopeMatch.Greedy,
1641
- userScope: { ja4Hash },
1642
- userScopeMatch: FilterScopeMatch.Greedy,
1643
- blockOnly: true,
1644
- });
1645
- expect(blockOnly).toHaveLength(2);
1646
- for (const rule of blockOnly) {
1647
- expect(rule.type).toBe(AccessPolicyType.Block);
1648
- expect(rule.clientId).toBe(clientId);
1649
- }
1650
- const blockHashes = new Set(blockOnly.map((r) => r.coords));
1651
- expect(blockHashes).toEqual(new Set([undefined, "[[[1,2]]]"]));
1652
- });
1653
-
1654
- test("blockOnly composes with matchingFieldsOnly on the ranked hot path — Restrict rules never come back", async () => {
1655
- // Exercises the production hot path: greedy user-scope match
1656
- // with matchingFieldsOnly=true (the FT.AGGREGATE-ranked
1657
- // branch). The greedy ja4 query is permissive enough that
1658
- // `ruleApplies` is still the last word on whether a rule
1659
- // applies; the only guarantee this layer is supposed to
1660
- // uphold is that Restrict candidates are gone before JS sees
1661
- // them. That's what blockOnly is for.
1662
- const clientId = getUniqueString();
1663
- const ja4Hash = "t13d_hotpath";
1664
-
1665
- const blockMatch: AccessRule = {
1666
- type: AccessPolicyType.Block,
1667
- clientId,
1668
- ja4Hash,
1669
- };
1670
- const restrictMatch: AccessRule = {
1671
- type: AccessPolicyType.Restrict,
1672
- clientId,
1673
- ja4Hash,
1674
- };
1675
- const restrictMatchWithCoords: AccessRule = {
1676
- type: AccessPolicyType.Restrict,
1677
- clientId,
1678
- ja4Hash,
1679
- coords: "[[[5,6]]]",
1680
- };
1681
-
1682
- await insertRules([blockMatch, restrictMatch, restrictMatchWithCoords]);
1683
-
1684
- const found = await accessRulesReader.findRules(
1685
- {
1686
- policyScope: { clientId },
1687
- policyScopeMatch: FilterScopeMatch.Greedy,
1688
- userScope: { ja4Hash },
1689
- userScopeMatch: FilterScopeMatch.Greedy,
1690
- blockOnly: true,
1691
- },
1692
- true, // matchingFieldsOnly — ranked path
1693
- );
1694
-
1695
- // Restrict rules must not appear; at least the matching
1696
- // Block rule must be present.
1697
- expect(found.some((r) => r.type === AccessPolicyType.Restrict)).toBe(
1698
- false,
1699
- );
1700
- expect(
1701
- found.some(
1702
- (r) => r.type === AccessPolicyType.Block && r.ja4Hash === ja4Hash,
1703
- ),
1704
- ).toBe(true);
1705
- });
1706
-
1707
- test("specific-IP Restrict rule survives when many higher-specificity irrelevant rules co-exist (2026-07-10 Twickets regression)", async () => {
1708
- // Regression guard for the 2026-07-10 Twickets incident: a
1709
- // portal-authored Restrict rule with scope
1710
- // `clientId + numericIp` (specificity 2, severity 0) was
1711
- // silently dropped from the frictionless access-policy lookup
1712
- // when the tenant had many higher-specificity irrelevant
1713
- // rules on the same clientId. The old FT.AGGREGATE ranker
1714
- // used for non-block lookups capped candidates at top-20 by
1715
- // populated-field count; those 20 slots got filled by rules
1716
- // with clientId + numericIp + numericIpMaskMin (SIMD_REPLAY
1717
- // v6 shape, specificity 3, severity 1) or
1718
- // clientId + ja4Hash + coords (SUDDEN_VOLUME_INCREASE shape,
1719
- // specificity 3, severity 1) that all matched the greedy
1720
- // `ismissing(@headHash) | ismissing(@coords) | ismissing(@headersHash)`
1721
- // disjunction. None of them applied to the request under
1722
- // `ruleApplies` — they were emitted from other users'
1723
- // activity — so the JS-side ranker returned [] and the
1724
- // frictionless decision fell through to `default_pow`.
1725
- //
1726
- // The split-query path can't hit that failure: each probe
1727
- // hits a discriminating posting list, so the ip:exact probe
1728
- // returns exactly the rules that literally match this IP.
1729
- const clientId = getUniqueString();
1730
-
1731
- // The rule that must survive: a portal "Too Many Requests"
1732
- // Restrict/image rule scoped only to (clientId, numericIp).
1733
- const targetIp = 1376899398n; // 82.17.209.70 — Twickets rule
1734
- const targetRule: AccessRule = {
1735
- type: AccessPolicyType.Restrict,
1736
- clientId,
1737
- numericIp: targetIp,
1738
- description: "Too Many Requests",
1739
- };
1740
-
1741
- // 40 irrelevant Block rules with higher specificity than
1742
- // the target rule. Two shapes drawn from the live Twickets
1743
- // tenant that dominated the top-20:
1744
- //
1745
- // (a) SIMD_REPLAY v6-style — clientId + numericIp +
1746
- // numericIpMaskMin. These have a *different* numericIp
1747
- // from the request, so the exact-IP probe skips them.
1748
- // (b) SUDDEN_VOLUME_INCREASE-style — clientId + ja4Hash +
1749
- // coords. Different ja4Hash from the request so those
1750
- // probes miss them too.
1751
- //
1752
- // Under the old ranker every one of these would fill the top
1753
- // slots and push the target rule off the end.
1754
- const irrelevantRules: AccessRule[] = [];
1755
- for (let i = 0; i < 20; i++) {
1756
- irrelevantRules.push({
1757
- type: AccessPolicyType.Block,
1758
- clientId,
1759
- numericIp: BigInt(2000000000 + i),
1760
- numericIpMaskMin: BigInt(2000000000 + i),
1761
- numericIpMaskMax: BigInt(2000000000 + i),
1762
- description: "SIMD_REPLAY",
1763
- });
1764
- }
1765
- for (let i = 0; i < 20; i++) {
1766
- irrelevantRules.push({
1767
- type: AccessPolicyType.Block,
1768
- clientId,
1769
- ja4Hash: `t13d_other_${i}`,
1770
- coords: `[[[${100 + i},${200 + i}]]]`,
1771
- description: "SUDDEN_VOLUME_INCREASE",
1772
- });
1773
- }
1774
-
1775
- await insertRules([targetRule, ...irrelevantRules]);
1776
-
1777
- // Frictionless access-policy lookup shape — greedy match,
1778
- // matchingFieldsOnly=true, no blockOnly.
1779
- const found = await accessRulesReader.findRules(
1780
- {
1781
- policyScope: { clientId },
1782
- policyScopeMatch: FilterScopeMatch.Greedy,
1783
- userScope: {
1784
- numericIp: targetIp,
1785
- ja4Hash: "t13d1516h2_request_ja4",
1786
- userAgentHash: "sha256:request_ua",
1787
- userId: getUniqueString(),
1788
- },
1789
- userScopeMatch: FilterScopeMatch.Greedy,
1790
- },
1791
- true, // matchingFieldsOnly — production hot path
1792
- );
1793
-
1794
- // The target rule MUST come back. Under the old ranker it
1795
- // was truncated; under the split path the ip:exact probe
1796
- // returns exactly this one rule, so it can't be crowded
1797
- // out by irrelevant candidates from other probes.
1798
- const targetFound = found.find(
1799
- (r) =>
1800
- r.type === AccessPolicyType.Restrict &&
1801
- r.numericIp === targetIp &&
1802
- r.description === "Too Many Requests",
1803
- );
1804
- expect(targetFound).toBeDefined();
1805
- });
1806
- });
1807
-
1808
- afterAll(async () => {
1809
- await redisClient.flushAll();
1810
- });
1811
- });