@prosopo/user-access-policy 3.9.1 → 3.10.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.
@@ -0,0 +1,338 @@
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
+ // Production observation on pronode10 (2026-06-15):
35
+ // - ~7,500 rules in the access-rule index
36
+ // - greedy + FT.AGGREGATE pulled ~1,190 hashes per request and pegged
37
+ // provider1 Node thread at ~125% CPU.
38
+ // - Strict-match + server-side rank pulls at most SERVER_SIDE_RANK_TOP_N
39
+ // hashes per request, so the cost is independent of total rule count.
40
+ //
41
+ // This benchmark seeds 10k rules with a realistic specificity distribution,
42
+ // runs N findRules calls in matchingFieldsOnly=true mode, and asserts
43
+ // per-call latency stays inside generous CI thresholds. The numbers below
44
+ // are intentionally loose; healthy local runs typically come in 5-10x
45
+ // under cap. The point is to catch regressions where someone re-introduces
46
+ // HGETALL fanout into the hot path.
47
+
48
+ const RULE_COUNT = 10_000;
49
+ const QUERY_COUNT = 200;
50
+
51
+ // CI thresholds. Local development on a warm redis container is steady
52
+ // around p50=20ms / p99=25ms over 10k rules; thresholds set ~3-5x above
53
+ // that to absorb noisy shared CI runners. If these start failing
54
+ // regularly, look for changes that re-introduce per-rule HGETALL fanout
55
+ // or break the SORTBY MAX short-circuit on the Redis side.
56
+ const P50_LATENCY_MS = 80;
57
+ const P99_LATENCY_MS = 250;
58
+
59
+ // Build a deterministic distribution of rules that mirrors production
60
+ // roughly:
61
+ // ~70% low-specificity (1 field, e.g. ja4-only or country-only)
62
+ // ~20% medium (2-3 fields, typical anomaly detector emission)
63
+ // ~10% high (4+ fields, manual portal rules with full scope)
64
+ const buildBenchmarkRule = (i: number): AccessRule => {
65
+ const bucket = i % 10;
66
+ const ja4 = `t13d_bench_${(i % 50).toString().padStart(3, "0")}`;
67
+ const asn = 1000 + (i % 500);
68
+ // description is included in the content hash that generates the
69
+ // rule's Redis key, so a unique description ensures every synthetic
70
+ // rule lands on its own key. It doesn't affect specificity scoring
71
+ // or the strict-match filter.
72
+ const description = `bench-rule-${i}`;
73
+
74
+ if (bucket < 7) {
75
+ // low specificity — anomaly detector style
76
+ const variant = i % 3;
77
+ if (variant === 0)
78
+ return { type: AccessPolicyType.Block, description, ja4Hash: ja4 };
79
+ if (variant === 1)
80
+ return {
81
+ type: AccessPolicyType.Block,
82
+ description,
83
+ asn,
84
+ };
85
+ return {
86
+ type: AccessPolicyType.Restrict,
87
+ description,
88
+ countryCode: `C${i % 30}`,
89
+ };
90
+ }
91
+
92
+ if (bucket < 9) {
93
+ // medium specificity — client-scoped + 1-2 fields
94
+ return {
95
+ type: AccessPolicyType.Block,
96
+ description,
97
+ clientId: `client${i % 20}`,
98
+ ja4Hash: ja4,
99
+ ...(i % 2 === 0 && { userAgentHash: `ua${i % 100}` }),
100
+ };
101
+ }
102
+
103
+ // high specificity — full scope portal rules
104
+ return {
105
+ type: AccessPolicyType.Block,
106
+ description,
107
+ clientId: `client${i % 20}`,
108
+ ja4Hash: ja4,
109
+ userAgentHash: `ua${i % 100}`,
110
+ headersHash: `hh${i % 200}`,
111
+ countryCode: `C${i % 30}`,
112
+ };
113
+ };
114
+
115
+ // Deterministic request-scope generator. Every request populates the
116
+ // full set of user-scope fields the rules use, so under strict-match
117
+ // semantics rules whose populated fields are a subset of the request
118
+ // scope can actually apply. Mirrors a fully-instrumented production
119
+ // request (ja4 + UA + headers + country + asn all known).
120
+ const buildBenchmarkRequest = (i: number) => {
121
+ return {
122
+ clientId: `client${i % 20}`,
123
+ scope: {
124
+ ja4Hash: `t13d_bench_${(i % 50).toString().padStart(3, "0")}`,
125
+ userAgentHash: `ua${i % 100}`,
126
+ headersHash: `hh${i % 200}`,
127
+ countryCode: `C${i % 30}`,
128
+ asn: 1000 + (i % 500),
129
+ },
130
+ };
131
+ };
132
+
133
+ const percentile = (sortedMs: number[], q: number): number => {
134
+ if (sortedMs.length === 0) return 0;
135
+ const idx = Math.min(
136
+ sortedMs.length - 1,
137
+ Math.floor((sortedMs.length - 1) * q),
138
+ );
139
+ const v = sortedMs[idx];
140
+ return v ?? 0;
141
+ };
142
+
143
+ describe("redisRulesReader ranked-path benchmark", () => {
144
+ let redisConnection: RedisConnection;
145
+ let redisClient: RedisClientType;
146
+ let reader: RedisRulesReader;
147
+
148
+ const mockLogger = new Proxy(
149
+ {},
150
+ {
151
+ get: () => () => {},
152
+ },
153
+ ) as unknown as Logger;
154
+
155
+ // Track every key we insert so cleanup deletes only our rules,
156
+ // leaving any other test suite's data and the shared index intact.
157
+ const seededKeys: string[] = [];
158
+
159
+ beforeAll(async () => {
160
+ redisConnection = createTestRedisConnection();
161
+ redisClient = await setupRedisIndex(
162
+ redisConnection,
163
+ accessRulesRedisIndex,
164
+ mockLogger,
165
+ ).getClient();
166
+ reader = new RedisRulesReader(redisClient, mockLogger);
167
+
168
+ // Seed RULE_COUNT rules in batches of 1000 — same chunk size the
169
+ // production writer uses, mirrors realistic insert behaviour.
170
+ const rules: AccessRule[] = [];
171
+ for (let i = 0; i < RULE_COUNT; i++) {
172
+ rules.push(buildBenchmarkRule(i));
173
+ }
174
+ await executeBatchesSequentially(
175
+ chunkIntoBatches(rules, 1000),
176
+ async (batch) => {
177
+ const multi = redisClient.multi();
178
+ for (const rule of batch) {
179
+ const key = getAccessRuleRedisKey(rule);
180
+ seededKeys.push(key);
181
+ multi.hSet(key, getRedisRuleValue(rule));
182
+ }
183
+ await multi.exec();
184
+ },
185
+ );
186
+
187
+ const indexInfo = await redisClient.ft.info(ACCESS_RULES_REDIS_INDEX_NAME);
188
+ // Description is unique per rule so every rule should land on
189
+ // its own key; allow a 10% margin for any incidental dedupe.
190
+ expect(indexInfo.num_docs).toBeGreaterThan(RULE_COUNT * 0.9);
191
+ }, 120_000);
192
+
193
+ afterAll(async () => {
194
+ // Delete only the keys this suite inserted; do not flushAll
195
+ // because we share the redis instance (and the index) with the
196
+ // other integration test file.
197
+ await executeBatchesSequentially(
198
+ chunkIntoBatches(seededKeys, 1000),
199
+ async (batch) => {
200
+ const multi = redisClient.multi();
201
+ for (const key of batch) {
202
+ multi.del(key);
203
+ }
204
+ await multi.exec();
205
+ },
206
+ );
207
+ });
208
+
209
+ test(`ranked findRules p50/p99 over ${QUERY_COUNT} queries against ${RULE_COUNT} rules`, async () => {
210
+ // warm up — first call pays for index page-in
211
+ await reader.findRules(
212
+ {
213
+ policyScope: { clientId: "warmup" },
214
+ policyScopeMatch: FilterScopeMatch.Greedy,
215
+ userScope: { ja4Hash: "warmup" },
216
+ userScopeMatch: FilterScopeMatch.Greedy,
217
+ },
218
+ true,
219
+ );
220
+
221
+ const samples: number[] = [];
222
+ for (let i = 0; i < QUERY_COUNT; i++) {
223
+ const { clientId, scope } = buildBenchmarkRequest(i);
224
+ const start = performance.now();
225
+ await reader.findRules(
226
+ {
227
+ policyScope: { clientId },
228
+ policyScopeMatch: FilterScopeMatch.Greedy,
229
+ userScope: scope,
230
+ userScopeMatch: FilterScopeMatch.Greedy,
231
+ },
232
+ true,
233
+ );
234
+ samples.push(performance.now() - start);
235
+ }
236
+
237
+ samples.sort((a, b) => a - b);
238
+ const p50 = percentile(samples, 0.5);
239
+ const p99 = percentile(samples, 0.99);
240
+ const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
241
+
242
+ console.log(
243
+ `ranked findRules: avg=${avg.toFixed(2)}ms p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms n=${samples.length}`,
244
+ );
245
+
246
+ expect(p50).toBeLessThan(P50_LATENCY_MS);
247
+ expect(p99).toBeLessThan(P99_LATENCY_MS);
248
+ }, 120_000);
249
+
250
+ test("ranked findRules result size is bounded by SERVER_SIDE_RANK_TOP_N", async () => {
251
+ // Fully-populated request that should apply to many seeded rules
252
+ // — the new path must cap at SERVER_SIDE_RANK_TOP_N (20).
253
+ const { clientId, scope } = buildBenchmarkRequest(0);
254
+ const results = await reader.findRules(
255
+ {
256
+ policyScope: { clientId },
257
+ policyScopeMatch: FilterScopeMatch.Greedy,
258
+ userScope: scope,
259
+ userScopeMatch: FilterScopeMatch.Greedy,
260
+ },
261
+ true,
262
+ );
263
+ expect(results.length).toBeLessThanOrEqual(20);
264
+ expect(results.length).toBeGreaterThan(0);
265
+ }, 60_000);
266
+
267
+ test("HGETALL-free: ranked path returns full rules without per-rule fanout", async () => {
268
+ // The benefit of the ranked path in production is *not* raw
269
+ // localhost latency (the strict-match index walk is heavier
270
+ // than a greedy FT.SEARCH on a quiet box). It's that the
271
+ // ranked path returns full rule bodies inside the single
272
+ // FT.AGGREGATE reply, with no follow-up HGETALL fanout. In
273
+ // production each HGETALL costs ~0.5ms of network RTT over
274
+ // the Docker bridge, so cutting hundreds of round trips per
275
+ // request is the win that doesn't appear in a localhost
276
+ // timing test.
277
+ //
278
+ // This test stands in for that: confirm the ranked path
279
+ // produces fully-populated AccessRule objects from one
280
+ // aggregate call (no separate hash fetches needed).
281
+ const { clientId, scope } = buildBenchmarkRequest(0);
282
+ const ranked = await reader.findRules(
283
+ {
284
+ policyScope: { clientId },
285
+ policyScopeMatch: FilterScopeMatch.Greedy,
286
+ userScope: scope,
287
+ userScopeMatch: FilterScopeMatch.Greedy,
288
+ },
289
+ true,
290
+ );
291
+
292
+ expect(ranked.length).toBeGreaterThan(0);
293
+ // Every returned record must have the policy fields populated —
294
+ // proves the LOAD pipeline includes them rather than relying
295
+ // on a follow-up HGETALL.
296
+ for (const rule of ranked) {
297
+ expect(rule.type).toBeDefined();
298
+ }
299
+ }, 60_000);
300
+
301
+ test("ranked findRules returns rules in specificity-descending order", async () => {
302
+ // A request that matches the high-specificity bucket should
303
+ // surface the rule with the most populated fields first.
304
+ const { clientId, scope } = buildBenchmarkRequest(9);
305
+ const results = await reader.findRules(
306
+ {
307
+ policyScope: { clientId },
308
+ policyScopeMatch: FilterScopeMatch.Greedy,
309
+ userScope: scope,
310
+ userScopeMatch: FilterScopeMatch.Greedy,
311
+ },
312
+ true,
313
+ );
314
+
315
+ if (results.length === 0) return;
316
+ const top = results[0];
317
+ if (!top) return;
318
+
319
+ // The top rule must have at least as many populated user-scope
320
+ // fields as any other returned rule.
321
+ const countFields = (r: AccessRule): number =>
322
+ (r.userId ? 1 : 0) +
323
+ (r.ja4Hash ? 1 : 0) +
324
+ (r.headersHash ? 1 : 0) +
325
+ (r.userAgentHash ? 1 : 0) +
326
+ (r.headHash ? 1 : 0) +
327
+ (r.coords ? 1 : 0) +
328
+ (r.countryCode ? 1 : 0) +
329
+ (r.asn !== undefined ? 1 : 0) +
330
+ (r.clientId ? 1 : 0) +
331
+ (r.numericIp !== undefined || r.numericIpMaskMin !== undefined ? 1 : 0);
332
+
333
+ const topScore = countFields(top);
334
+ for (const r of results) {
335
+ expect(countFields(r)).toBeLessThanOrEqual(topScore);
336
+ }
337
+ }, 60_000);
338
+ });