@prosopo/user-access-policy 3.9.1 → 3.10.1
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 +35 -35
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +28 -0
- package/dist/cjs/redis/reader/redisRulesReader.cjs +120 -17
- package/dist/redis/reader/redisRulesReader.d.ts +4 -2
- package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesReader.js +121 -18
- package/dist/redis/reader/redisRulesReader.js.map +1 -1
- package/dist/tests/redis/redisRulesReaderRank.benchmark.integration.test.d.ts +2 -0
- package/dist/tests/redis/redisRulesReaderRank.benchmark.integration.test.d.ts.map +1 -0
- package/dist/tests/redis/redisRulesReaderRank.benchmark.integration.test.js +189 -0
- package/dist/tests/redis/redisRulesReaderRank.benchmark.integration.test.js.map +1 -0
- package/package.json +8 -8
- package/src/redis/reader/redisRulesReader.ts +191 -33
- package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +338 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +12 -1
|
@@ -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
|
+
});
|