@prosopo/user-access-policy 3.14.14 → 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.
- package/.turbo/turbo-build$colon$cjs.log +2 -5
- package/.turbo/turbo-build$colon$tsc.log +14 -17
- package/.turbo/turbo-build.log +3 -9
- package/CHANGELOG.md +18 -0
- package/LICENSE +201 -0
- package/package.json +83 -81
- package/src/.export.ts +0 -65
- package/src/api/.export.ts +0 -25
- package/src/api/accessRulesApiClient.ts +0 -13
- package/src/api/delete/.export.ts +0 -18
- package/src/api/delete/deleteAllRules.ts +0 -47
- package/src/api/delete/deleteRuleGroups.ts +0 -96
- package/src/api/delete/deleteRules.ts +0 -87
- package/src/api/read/.export.ts +0 -25
- package/src/api/read/fetchRules.ts +0 -96
- package/src/api/read/findRuleIds.ts +0 -97
- package/src/api/read/getMissingIds.ts +0 -81
- package/src/api/ruleApiRoutes.ts +0 -146
- package/src/api/rulesApiClient.ts +0 -154
- package/src/api/write/.export.ts +0 -15
- package/src/api/write/insertRules.ts +0 -185
- package/src/api/write/rehashRules.ts +0 -85
- package/src/classifyBrowser.ts +0 -77
- package/src/classifyOs.ts +0 -74
- package/src/headerMatch.ts +0 -180
- package/src/matchedRule.ts +0 -99
- package/src/mongoose/.export.ts +0 -15
- package/src/mongoose/mongooseRuleSchema.ts +0 -75
- package/src/redis/.export.ts +0 -17
- package/src/redis/reader/redisAggregate.ts +0 -157
- package/src/redis/reader/redisRulesQuery.ts +0 -268
- package/src/redis/reader/redisRulesReader.ts +0 -738
- package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
- package/src/redis/redisClient.ts +0 -125
- package/src/redis/redisRuleIndex.ts +0 -121
- package/src/redis/redisRulesStorage.ts +0 -68
- package/src/redis/redisRulesWriter.ts +0 -172
- package/src/rule.ts +0 -122
- package/src/ruleInput/.export.ts +0 -19
- package/src/ruleInput/policyInput.ts +0 -120
- package/src/ruleInput/ruleInput.ts +0 -123
- package/src/ruleInput/userScopeInput.ts +0 -142
- package/src/ruleRecord.ts +0 -77
- package/src/rulesStorage.ts +0 -90
- package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
- package/src/tests/classifyBrowser.unit.test.ts +0 -138
- package/src/tests/classifyOs.unit.test.ts +0 -97
- package/src/tests/headerMatch.unit.test.ts +0 -350
- package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
- package/src/tests/matchedRule.unit.test.ts +0 -184
- package/src/tests/policyInput.unit.test.ts +0 -218
- package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
- package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
- package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
- package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
- package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
- package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
- package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
- package/src/tests/testLogger.ts +0 -38
- package/src/tests/transformRule.unit.test.ts +0 -345
- package/src/transformRule.ts +0 -133
- package/tsconfig.cjs.json +0 -41
- package/tsconfig.json +0 -47
- package/tsconfig.tsbuildinfo +0 -1
- 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
|
-
});
|