@prosopo/user-access-policy 3.11.2 → 3.12.0
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 +38 -37
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +39 -38
- package/CHANGELOG.md +30 -0
- package/dist/api/delete/deleteRules.d.ts +2 -2
- package/dist/api/delete/deleteRules.d.ts.map +1 -1
- package/dist/api/delete/deleteRules.js.map +1 -1
- package/dist/api/read/findRuleIds.d.ts +1 -1
- package/dist/api/read/findRuleIds.d.ts.map +1 -1
- package/dist/api/read/findRuleIds.js.map +1 -1
- package/dist/cjs/redis/reader/redisRulesQuery.cjs +3 -2
- package/dist/cjs/redis/reader/redisRulesReader.cjs +101 -1
- package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +78 -0
- package/dist/cjs/redis/redisRuleIndex.cjs +3 -1
- package/dist/cjs/redis/redisRulesWriter.cjs +17 -6
- package/dist/cjs/rule.cjs +2 -0
- package/dist/cjs/ruleInput/policyInput.cjs +9 -1
- package/dist/redis/reader/redisRulesQuery.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesQuery.js +4 -3
- package/dist/redis/reader/redisRulesQuery.js.map +1 -1
- package/dist/redis/reader/redisRulesReader.d.ts +1 -0
- package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesReader.js +101 -1
- package/dist/redis/reader/redisRulesReader.js.map +1 -1
- package/dist/redis/reader/redisRulesSplitQuery.d.ts +8 -0
- package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -0
- package/dist/redis/reader/redisRulesSplitQuery.js +78 -0
- package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -0
- package/dist/redis/redisRuleIndex.d.ts +1 -0
- package/dist/redis/redisRuleIndex.d.ts.map +1 -1
- package/dist/redis/redisRuleIndex.js +2 -0
- package/dist/redis/redisRuleIndex.js.map +1 -1
- package/dist/redis/redisRulesWriter.d.ts +1 -1
- package/dist/redis/redisRulesWriter.d.ts.map +1 -1
- package/dist/redis/redisRulesWriter.js +14 -3
- package/dist/redis/redisRulesWriter.js.map +1 -1
- package/dist/rule.d.ts +1 -0
- package/dist/rule.d.ts.map +1 -1
- package/dist/rule.js +3 -1
- package/dist/rule.js.map +1 -1
- package/dist/ruleInput/policyInput.d.ts +2 -2
- package/dist/ruleInput/policyInput.d.ts.map +1 -1
- package/dist/ruleInput/policyInput.js +10 -2
- package/dist/ruleInput/policyInput.js.map +1 -1
- package/dist/ruleInput/ruleInput.d.ts +6 -6
- package/dist/ruleInput/ruleInput.d.ts.map +1 -1
- package/dist/ruleInput/ruleInput.js.map +1 -1
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.d.ts +2 -0
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.d.ts.map +1 -0
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +79 -0
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -0
- package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.d.ts +2 -0
- package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.d.ts.map +1 -0
- package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.js +288 -0
- package/dist/tests/redis/redisRulesReaderLoad.benchmark.integration.test.js.map +1 -0
- package/package.json +3 -3
- package/src/api/delete/deleteRules.ts +7 -1
- package/src/api/read/findRuleIds.ts +3 -1
- package/src/redis/reader/redisRulesQuery.ts +12 -2
- package/src/redis/reader/redisRulesReader.ts +145 -7
- package/src/redis/reader/redisRulesSplitQuery.ts +170 -0
- package/src/redis/redisRuleIndex.ts +6 -0
- package/src/redis/redisRulesWriter.ts +19 -5
- package/src/rule.ts +12 -0
- package/src/ruleInput/policyInput.ts +17 -3
- package/src/ruleInput/ruleInput.ts +7 -1
- package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +132 -0
- package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +516 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,516 @@
|
|
|
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 type { Logger } from "@prosopo/logger";
|
|
17
|
+
import {
|
|
18
|
+
type RedisConnection,
|
|
19
|
+
createTestRedisConnection,
|
|
20
|
+
setupRedisIndex,
|
|
21
|
+
} from "@prosopo/redis-client";
|
|
22
|
+
import type { RedisClientType } from "redis";
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
24
|
+
import { RedisRulesReader } from "#policy/redis/reader/redisRulesReader.js";
|
|
25
|
+
import {
|
|
26
|
+
ACCESS_RULES_REDIS_INDEX_NAME,
|
|
27
|
+
accessRulesRedisIndex,
|
|
28
|
+
getAccessRuleRedisKey,
|
|
29
|
+
} from "#policy/redis/redisRuleIndex.js";
|
|
30
|
+
import { getRedisRuleValue } from "#policy/redis/redisRulesWriter.js";
|
|
31
|
+
import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
|
|
32
|
+
import { FilterScopeMatch } from "#policy/rulesStorage.js";
|
|
33
|
+
|
|
34
|
+
// Regression barrier for the block-lookup hot path against the shape
|
|
35
|
+
// of a large bulk-ban rule population.
|
|
36
|
+
//
|
|
37
|
+
// What this test defends against:
|
|
38
|
+
// 1. A bulk-ban script inserts on the order of 17k access-policy
|
|
39
|
+
// rules, mostly IP-only (individual IPs plus a few CIDR aggregates,
|
|
40
|
+
// no other scope fields).
|
|
41
|
+
// 2. Every incoming captcha request runs FT.AGGREGATE against
|
|
42
|
+
// `index:user-access-rules` with a strict-match ranked query. That
|
|
43
|
+
// query's LOAD + APPLY(exists()×11) + SORTBY step scales linearly
|
|
44
|
+
// in the tenant's total rule count. At 17k rules under production
|
|
45
|
+
// traffic the tail latency degrades sharply and upstream 502s
|
|
46
|
+
// cascade.
|
|
47
|
+
// 3. This benchmark seeds a matching rule shape and asserts the
|
|
48
|
+
// split-query path's latency stays flat regardless of population
|
|
49
|
+
// size — every probe hits its own index posting list (numericIp,
|
|
50
|
+
// numericIpMaskMin) so the cost is O(matches for this request),
|
|
51
|
+
// not O(total rules).
|
|
52
|
+
|
|
53
|
+
const IP_RULE_COUNT = 17_000;
|
|
54
|
+
const CIDR_RULE_COUNT = 1_300;
|
|
55
|
+
const MIXED_RULE_COUNT = 1_000;
|
|
56
|
+
const QUERY_COUNT = 300;
|
|
57
|
+
|
|
58
|
+
// Concurrent-storm config. Emulates a frontend retry storm: N requests
|
|
59
|
+
// in flight against Redis at once, sustained across multiple waves,
|
|
60
|
+
// with an identical banned scope so every request would hit the same
|
|
61
|
+
// FT probes. In production the process-wide verdict cache absorbs
|
|
62
|
+
// waves 2..N; this benchmark measures the reader path directly (no
|
|
63
|
+
// cache) so wave 1 numbers stand in for what the cache is protecting
|
|
64
|
+
// and wave 2..N numbers show the split query's steady-state behaviour
|
|
65
|
+
// under sustained connection multiplexing.
|
|
66
|
+
const STORM_CONCURRENCY = 100;
|
|
67
|
+
const STORM_WAVE_COUNT = 10;
|
|
68
|
+
|
|
69
|
+
// CI thresholds. Localhost warm-cache measurements land around
|
|
70
|
+
// p50 = 5-15 ms, p99 = 30-50 ms; thresholds set well above that to
|
|
71
|
+
// absorb noisy shared runners. A regression to the single-query design
|
|
72
|
+
// against this rule population would blow through both by
|
|
73
|
+
// order-of-magnitude margins, so the ceilings are the alarm not a
|
|
74
|
+
// performance target.
|
|
75
|
+
const P50_LATENCY_MS = 120;
|
|
76
|
+
const P99_LATENCY_MS = 400;
|
|
77
|
+
|
|
78
|
+
// Storm-mode thresholds: same story but with headroom for the extra
|
|
79
|
+
// tail from redis-client command queuing under N-in-flight concurrency.
|
|
80
|
+
// The single TCP connection multiplexes commands, so p99 grows with
|
|
81
|
+
// concurrency even when each command is individually cheap.
|
|
82
|
+
const STORM_P50_LATENCY_MS = 200;
|
|
83
|
+
const STORM_P99_LATENCY_MS = 800;
|
|
84
|
+
|
|
85
|
+
const ipv4 = (i: number): bigint => {
|
|
86
|
+
// 10.0.0.0/8 range — private, unambiguous, plenty of room to spread
|
|
87
|
+
// out over 17k unique addresses without wrapping past /8.
|
|
88
|
+
const base = 10n << 24n;
|
|
89
|
+
return base + BigInt(i);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const ipv4CidrRange = (i: number): { min: bigint; max: bigint } => {
|
|
93
|
+
// 192.168.<i%256>.0/24 style ranges. Deterministic and disjoint from
|
|
94
|
+
// the individual-IP range above so tests can target either shape
|
|
95
|
+
// unambiguously.
|
|
96
|
+
const octet = i % 256;
|
|
97
|
+
const base = (192n << 24n) + (168n << 16n) + (BigInt(octet) << 8n);
|
|
98
|
+
return { min: base, max: base + 255n };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Deterministic rule generators matched to the incident population shape.
|
|
102
|
+
const buildIpBlockRule = (i: number): AccessRule => ({
|
|
103
|
+
type: AccessPolicyType.Block,
|
|
104
|
+
description: `bulk-ban-ip-${i}`,
|
|
105
|
+
numericIp: ipv4(i),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const buildCidrBlockRule = (i: number): AccessRule => {
|
|
109
|
+
const { min, max } = ipv4CidrRange(i);
|
|
110
|
+
return {
|
|
111
|
+
type: AccessPolicyType.Block,
|
|
112
|
+
description: `bulk-ban-cidr-${i}`,
|
|
113
|
+
numericIpMaskMin: min,
|
|
114
|
+
numericIpMaskMax: max,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// A handful of "normal" client-scoped rules so the rule set isn't
|
|
119
|
+
// homogeneous — matches production's mixed anomaly-detector +
|
|
120
|
+
// manual-rule shape.
|
|
121
|
+
const buildMixedRule = (i: number): AccessRule => {
|
|
122
|
+
const variant = i % 4;
|
|
123
|
+
if (variant === 0) {
|
|
124
|
+
return {
|
|
125
|
+
type: AccessPolicyType.Block,
|
|
126
|
+
description: `mixed-${i}`,
|
|
127
|
+
clientId: `client${i % 30}`,
|
|
128
|
+
ja4Hash: `t13d_load_${i % 50}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (variant === 1) {
|
|
132
|
+
return {
|
|
133
|
+
type: AccessPolicyType.Restrict,
|
|
134
|
+
description: `mixed-${i}`,
|
|
135
|
+
countryCode: `C${i % 30}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (variant === 2) {
|
|
139
|
+
return {
|
|
140
|
+
type: AccessPolicyType.Block,
|
|
141
|
+
description: `mixed-${i}`,
|
|
142
|
+
asn: 1000 + (i % 500),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
type: AccessPolicyType.Block,
|
|
147
|
+
description: `mixed-${i}`,
|
|
148
|
+
clientId: `client${i % 30}`,
|
|
149
|
+
userAgentHash: `ua${i % 100}`,
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const percentile = (sortedMs: number[], q: number): number => {
|
|
154
|
+
if (sortedMs.length === 0) return 0;
|
|
155
|
+
const idx = Math.min(
|
|
156
|
+
sortedMs.length - 1,
|
|
157
|
+
Math.floor((sortedMs.length - 1) * q),
|
|
158
|
+
);
|
|
159
|
+
return sortedMs[idx] ?? 0;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
describe("redisRulesReader load-shape benchmark (17k IP-only rules)", () => {
|
|
163
|
+
let redisConnection: RedisConnection;
|
|
164
|
+
let redisClient: RedisClientType;
|
|
165
|
+
let reader: RedisRulesReader;
|
|
166
|
+
|
|
167
|
+
const mockLogger = new Proxy(
|
|
168
|
+
{},
|
|
169
|
+
{
|
|
170
|
+
get: () => () => {},
|
|
171
|
+
},
|
|
172
|
+
) as unknown as Logger;
|
|
173
|
+
|
|
174
|
+
const seededKeys: string[] = [];
|
|
175
|
+
|
|
176
|
+
beforeAll(async () => {
|
|
177
|
+
redisConnection = createTestRedisConnection();
|
|
178
|
+
redisClient = await setupRedisIndex(
|
|
179
|
+
redisConnection,
|
|
180
|
+
accessRulesRedisIndex,
|
|
181
|
+
mockLogger,
|
|
182
|
+
).getClient();
|
|
183
|
+
reader = new RedisRulesReader(redisClient, mockLogger);
|
|
184
|
+
|
|
185
|
+
const rules: AccessRule[] = [];
|
|
186
|
+
for (let i = 0; i < IP_RULE_COUNT; i++) rules.push(buildIpBlockRule(i));
|
|
187
|
+
for (let i = 0; i < CIDR_RULE_COUNT; i++) rules.push(buildCidrBlockRule(i));
|
|
188
|
+
for (let i = 0; i < MIXED_RULE_COUNT; i++) rules.push(buildMixedRule(i));
|
|
189
|
+
|
|
190
|
+
await executeBatchesSequentially(
|
|
191
|
+
chunkIntoBatches(rules, 1000),
|
|
192
|
+
async (batch) => {
|
|
193
|
+
const multi = redisClient.multi();
|
|
194
|
+
for (const rule of batch) {
|
|
195
|
+
const key = getAccessRuleRedisKey(rule);
|
|
196
|
+
seededKeys.push(key);
|
|
197
|
+
multi.hSet(key, getRedisRuleValue(rule));
|
|
198
|
+
}
|
|
199
|
+
await multi.exec();
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const indexInfo = await redisClient.ft.info(ACCESS_RULES_REDIS_INDEX_NAME);
|
|
204
|
+
// Description is unique per rule so every rule should own its
|
|
205
|
+
// key; allow 10% margin for any incidental dedupe.
|
|
206
|
+
const expectedRules = IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT;
|
|
207
|
+
expect(indexInfo.num_docs).toBeGreaterThan(expectedRules * 0.9);
|
|
208
|
+
}, 300_000);
|
|
209
|
+
|
|
210
|
+
afterAll(async () => {
|
|
211
|
+
await executeBatchesSequentially(
|
|
212
|
+
chunkIntoBatches(seededKeys, 1000),
|
|
213
|
+
async (batch) => {
|
|
214
|
+
const multi = redisClient.multi();
|
|
215
|
+
for (const key of batch) {
|
|
216
|
+
multi.del(key);
|
|
217
|
+
}
|
|
218
|
+
await multi.exec();
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
}, 60_000);
|
|
222
|
+
|
|
223
|
+
test(`split-query hot path stays flat under ${IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT} rules`, async () => {
|
|
224
|
+
// Warm-up: first call pays for RediSearch index page-in.
|
|
225
|
+
await reader.findRules(
|
|
226
|
+
{
|
|
227
|
+
policyScope: { clientId: "warmup" },
|
|
228
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
229
|
+
userScope: { numericIp: ipv4(999_999) },
|
|
230
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
231
|
+
blockOnly: true,
|
|
232
|
+
},
|
|
233
|
+
true,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const samples: number[] = [];
|
|
237
|
+
for (let i = 0; i < QUERY_COUNT; i++) {
|
|
238
|
+
// Half the queries target seeded IPs (hits), half target IPs
|
|
239
|
+
// outside the seeded range (misses). Both must be fast.
|
|
240
|
+
const isHit = i % 2 === 0;
|
|
241
|
+
const numericIp = isHit
|
|
242
|
+
? ipv4(i % IP_RULE_COUNT)
|
|
243
|
+
: ipv4(IP_RULE_COUNT + i + 1_000_000);
|
|
244
|
+
|
|
245
|
+
const start = performance.now();
|
|
246
|
+
await reader.findRules(
|
|
247
|
+
{
|
|
248
|
+
policyScope: { clientId: `client${i % 30}` },
|
|
249
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
250
|
+
userScope: {
|
|
251
|
+
numericIp,
|
|
252
|
+
ja4Hash: `t13d_load_${i % 50}`,
|
|
253
|
+
userAgentHash: `ua${i % 100}`,
|
|
254
|
+
countryCode: `C${i % 30}`,
|
|
255
|
+
asn: 1000 + (i % 500),
|
|
256
|
+
},
|
|
257
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
258
|
+
blockOnly: true,
|
|
259
|
+
},
|
|
260
|
+
true,
|
|
261
|
+
);
|
|
262
|
+
samples.push(performance.now() - start);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
samples.sort((a, b) => a - b);
|
|
266
|
+
const p50 = percentile(samples, 0.5);
|
|
267
|
+
const p99 = percentile(samples, 0.99);
|
|
268
|
+
const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
269
|
+
|
|
270
|
+
console.log(
|
|
271
|
+
`split-query load: avg=${avg.toFixed(2)}ms p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms n=${samples.length} (${IP_RULE_COUNT} IPs + ${CIDR_RULE_COUNT} CIDRs + ${MIXED_RULE_COUNT} mixed)`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(p50).toBeLessThan(P50_LATENCY_MS);
|
|
275
|
+
expect(p99).toBeLessThan(P99_LATENCY_MS);
|
|
276
|
+
}, 300_000);
|
|
277
|
+
|
|
278
|
+
test("finds exact-IP bulk-ban rule for matching request", async () => {
|
|
279
|
+
// Pick an IP known to have been seeded as an individual-IP ban.
|
|
280
|
+
const targetIp = ipv4(42);
|
|
281
|
+
const results = await reader.findRules(
|
|
282
|
+
{
|
|
283
|
+
policyScope: { clientId: "client1" },
|
|
284
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
285
|
+
userScope: {
|
|
286
|
+
numericIp: targetIp,
|
|
287
|
+
ja4Hash: "t13d_load_1",
|
|
288
|
+
},
|
|
289
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
290
|
+
blockOnly: true,
|
|
291
|
+
},
|
|
292
|
+
true,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// The exact-IP rule must be among the returned candidates —
|
|
296
|
+
// this is the "rule that must never be missed" property.
|
|
297
|
+
const found = results.find((r) => r.numericIp === targetIp);
|
|
298
|
+
expect(found).toBeDefined();
|
|
299
|
+
expect(found?.type).toBe(AccessPolicyType.Block);
|
|
300
|
+
}, 60_000);
|
|
301
|
+
|
|
302
|
+
test("finds CIDR-range bulk-ban rule for request IP inside the range", async () => {
|
|
303
|
+
// Pick a CIDR range and a request IP known to fall inside it.
|
|
304
|
+
const { min, max } = ipv4CidrRange(7);
|
|
305
|
+
const requestIp = min + 42n;
|
|
306
|
+
expect(requestIp).toBeLessThanOrEqual(max);
|
|
307
|
+
|
|
308
|
+
const results = await reader.findRules(
|
|
309
|
+
{
|
|
310
|
+
policyScope: { clientId: "client1" },
|
|
311
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
312
|
+
userScope: { numericIp: requestIp },
|
|
313
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
314
|
+
blockOnly: true,
|
|
315
|
+
},
|
|
316
|
+
true,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const found = results.find(
|
|
320
|
+
(r) =>
|
|
321
|
+
r.numericIpMaskMin === min &&
|
|
322
|
+
r.numericIpMaskMax === max &&
|
|
323
|
+
r.type === AccessPolicyType.Block,
|
|
324
|
+
);
|
|
325
|
+
expect(found).toBeDefined();
|
|
326
|
+
}, 60_000);
|
|
327
|
+
|
|
328
|
+
test("finds ja4-scoped rule via the split-query ja4 probe", async () => {
|
|
329
|
+
// The mixed population includes ja4-scoped rules; a request
|
|
330
|
+
// with matching ja4Hash must surface the rule even when the
|
|
331
|
+
// request IP has no matching numericIp entry.
|
|
332
|
+
const results = await reader.findRules(
|
|
333
|
+
{
|
|
334
|
+
policyScope: { clientId: "client0" },
|
|
335
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
336
|
+
userScope: {
|
|
337
|
+
numericIp: ipv4(999_998),
|
|
338
|
+
ja4Hash: "t13d_load_0",
|
|
339
|
+
},
|
|
340
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
341
|
+
blockOnly: true,
|
|
342
|
+
},
|
|
343
|
+
true,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const ja4Rule = results.find(
|
|
347
|
+
(r) => r.ja4Hash === "t13d_load_0" && r.clientId === "client0",
|
|
348
|
+
);
|
|
349
|
+
expect(ja4Rule).toBeDefined();
|
|
350
|
+
}, 60_000);
|
|
351
|
+
|
|
352
|
+
test("returns no match when request IP is outside every rule range", async () => {
|
|
353
|
+
// Way outside every seeded range.
|
|
354
|
+
const results = await reader.findRules(
|
|
355
|
+
{
|
|
356
|
+
policyScope: { clientId: "client99" },
|
|
357
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
358
|
+
userScope: { numericIp: ipv4(9_999_999) },
|
|
359
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
360
|
+
blockOnly: true,
|
|
361
|
+
},
|
|
362
|
+
true,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// No IP-scoped rule should apply; only fall-through matches
|
|
366
|
+
// (mixed rules keyed on other fields) could surface here, and
|
|
367
|
+
// we're not populating any of those on this request.
|
|
368
|
+
const ipMatch = results.find(
|
|
369
|
+
(r) =>
|
|
370
|
+
r.numericIp !== undefined ||
|
|
371
|
+
(r.numericIpMaskMin !== undefined && r.numericIpMaskMax !== undefined),
|
|
372
|
+
);
|
|
373
|
+
expect(ipMatch).toBeUndefined();
|
|
374
|
+
}, 60_000);
|
|
375
|
+
|
|
376
|
+
test(`concurrent retry-storm: ${STORM_CONCURRENCY} in-flight × ${STORM_WAVE_COUNT} waves against ${IP_RULE_COUNT + CIDR_RULE_COUNT + MIXED_RULE_COUNT} rules`, async () => {
|
|
377
|
+
// Emulates the incident's frontend-retry pattern: the client
|
|
378
|
+
// keeps retrying the same banned scope, and each retry starts
|
|
379
|
+
// before the previous one has completed. In production the
|
|
380
|
+
// provider-side verdict cache absorbs waves 2..N; here we run
|
|
381
|
+
// the reader directly (no cache) so the numbers show the
|
|
382
|
+
// worst-case Redis-only path — what the cache is protecting
|
|
383
|
+
// against under stampede.
|
|
384
|
+
//
|
|
385
|
+
// A single scope is used across every request so the FT
|
|
386
|
+
// probes are byte-for-byte identical. That means every wave
|
|
387
|
+
// is a candidate for the same posting-list intersections;
|
|
388
|
+
// under the *old* single-query path this would compound with
|
|
389
|
+
// the per-request APPLY(exists()×11) cost and crush tail
|
|
390
|
+
// latency. Under the split path each probe still hits its
|
|
391
|
+
// own posting list and the only added cost is command
|
|
392
|
+
// queuing on the shared TCP connection.
|
|
393
|
+
|
|
394
|
+
// Warm-up: page the index in and let redis-client establish
|
|
395
|
+
// pipelines before the first measured wave.
|
|
396
|
+
await reader.findRules(
|
|
397
|
+
{
|
|
398
|
+
policyScope: { clientId: "client1" },
|
|
399
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
400
|
+
userScope: { numericIp: ipv4(999_997) },
|
|
401
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
402
|
+
blockOnly: true,
|
|
403
|
+
},
|
|
404
|
+
true,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Identical scope for every request. Hits a real rule
|
|
408
|
+
// (ipv4(42) is in the seeded IP-ban range) so each response
|
|
409
|
+
// carries the same non-empty verdict payload.
|
|
410
|
+
const stormScope = {
|
|
411
|
+
numericIp: ipv4(42),
|
|
412
|
+
ja4Hash: "t13d_load_1",
|
|
413
|
+
userAgentHash: "ua5",
|
|
414
|
+
countryCode: "C5",
|
|
415
|
+
asn: 1005,
|
|
416
|
+
};
|
|
417
|
+
const clientId = "client1";
|
|
418
|
+
|
|
419
|
+
const allSamples: number[] = [];
|
|
420
|
+
const perWave: Array<{
|
|
421
|
+
wave: number;
|
|
422
|
+
p50: number;
|
|
423
|
+
p99: number;
|
|
424
|
+
min: number;
|
|
425
|
+
max: number;
|
|
426
|
+
elapsedMs: number;
|
|
427
|
+
}> = [];
|
|
428
|
+
|
|
429
|
+
for (let wave = 0; wave < STORM_WAVE_COUNT; wave++) {
|
|
430
|
+
const waveStart = performance.now();
|
|
431
|
+
const waveSamples: number[] = [];
|
|
432
|
+
|
|
433
|
+
// Fire all N requests in parallel and wait for the whole
|
|
434
|
+
// wave to complete. This mirrors a burst of retries
|
|
435
|
+
// arriving inside one event-loop turn — every request
|
|
436
|
+
// starts before any has completed, so the redis client's
|
|
437
|
+
// command queue depth peaks at N.
|
|
438
|
+
await Promise.all(
|
|
439
|
+
Array.from({ length: STORM_CONCURRENCY }, async () => {
|
|
440
|
+
const start = performance.now();
|
|
441
|
+
const results = await reader.findRules(
|
|
442
|
+
{
|
|
443
|
+
policyScope: { clientId },
|
|
444
|
+
policyScopeMatch: FilterScopeMatch.Greedy,
|
|
445
|
+
userScope: stormScope,
|
|
446
|
+
userScopeMatch: FilterScopeMatch.Greedy,
|
|
447
|
+
blockOnly: true,
|
|
448
|
+
},
|
|
449
|
+
true,
|
|
450
|
+
);
|
|
451
|
+
const dur = performance.now() - start;
|
|
452
|
+
waveSamples.push(dur);
|
|
453
|
+
// Sanity: the storm scope has a matching exact-IP
|
|
454
|
+
// block rule, so every response must include it.
|
|
455
|
+
// A regression that drops responses under
|
|
456
|
+
// concurrency shows up here rather than as a
|
|
457
|
+
// silent under-count of samples.
|
|
458
|
+
expect(
|
|
459
|
+
results.find((r) => r.numericIp === stormScope.numericIp),
|
|
460
|
+
).toBeDefined();
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const waveElapsed = performance.now() - waveStart;
|
|
465
|
+
waveSamples.sort((a, b) => a - b);
|
|
466
|
+
const p50 = percentile(waveSamples, 0.5);
|
|
467
|
+
const p99 = percentile(waveSamples, 0.99);
|
|
468
|
+
const min = waveSamples[0] ?? 0;
|
|
469
|
+
const max = waveSamples[waveSamples.length - 1] ?? 0;
|
|
470
|
+
perWave.push({
|
|
471
|
+
wave: wave + 1,
|
|
472
|
+
p50,
|
|
473
|
+
p99,
|
|
474
|
+
min,
|
|
475
|
+
max,
|
|
476
|
+
elapsedMs: waveElapsed,
|
|
477
|
+
});
|
|
478
|
+
allSamples.push(...waveSamples);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
allSamples.sort((a, b) => a - b);
|
|
482
|
+
const overallP50 = percentile(allSamples, 0.5);
|
|
483
|
+
const overallP99 = percentile(allSamples, 0.99);
|
|
484
|
+
const overallAvg =
|
|
485
|
+
allSamples.reduce((a, b) => a + b, 0) / allSamples.length;
|
|
486
|
+
const totalRequests = STORM_CONCURRENCY * STORM_WAVE_COUNT;
|
|
487
|
+
const totalElapsed = perWave.reduce((sum, w) => sum + w.elapsedMs, 0);
|
|
488
|
+
const throughput = (totalRequests * 1000) / totalElapsed;
|
|
489
|
+
|
|
490
|
+
console.log(
|
|
491
|
+
`storm ${STORM_CONCURRENCY}×${STORM_WAVE_COUNT}: avg=${overallAvg.toFixed(2)}ms p50=${overallP50.toFixed(2)}ms p99=${overallP99.toFixed(2)}ms ` +
|
|
492
|
+
`throughput=${throughput.toFixed(0)} req/s total=${totalElapsed.toFixed(0)}ms`,
|
|
493
|
+
);
|
|
494
|
+
// Per-wave breakdown so a regression that only affects
|
|
495
|
+
// first-wave stampede (cache-miss shape) shows up
|
|
496
|
+
// independently from steady-state numbers.
|
|
497
|
+
for (const w of perWave) {
|
|
498
|
+
console.log(
|
|
499
|
+
` wave ${w.wave.toString().padStart(2)}: p50=${w.p50.toFixed(2)}ms p99=${w.p99.toFixed(2)}ms ` +
|
|
500
|
+
`min=${w.min.toFixed(2)}ms max=${w.max.toFixed(2)}ms wave-elapsed=${w.elapsedMs.toFixed(0)}ms`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
expect(overallP50).toBeLessThan(STORM_P50_LATENCY_MS);
|
|
505
|
+
expect(overallP99).toBeLessThan(STORM_P99_LATENCY_MS);
|
|
506
|
+
// Wave-1 stampede check: even the cold-cache wave (which is
|
|
507
|
+
// what the production cache absorbs) has to stay tractable —
|
|
508
|
+
// a regression that only hurts first-request-per-scope
|
|
509
|
+
// latency would otherwise hide behind steady-state numbers.
|
|
510
|
+
const firstWave = perWave[0];
|
|
511
|
+
expect(firstWave).toBeDefined();
|
|
512
|
+
if (firstWave) {
|
|
513
|
+
expect(firstWave.p99).toBeLessThan(STORM_P99_LATENCY_MS);
|
|
514
|
+
}
|
|
515
|
+
}, 300_000);
|
|
516
|
+
});
|