@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.
@@ -0,0 +1,189 @@
1
+ import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
2
+ import { createTestRedisConnection, setupRedisIndex, } from "@prosopo/redis-client";
3
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
4
+ import { RedisRulesReader } from "#policy/redis/reader/redisRulesReader.js";
5
+ import { ACCESS_RULES_REDIS_INDEX_NAME, accessRulesRedisIndex, getAccessRuleRedisKey, } from "#policy/redis/redisRuleIndex.js";
6
+ import { getRedisRuleValue } from "#policy/redis/redisRulesWriter.js";
7
+ import { AccessPolicyType } from "#policy/rule.js";
8
+ import { FilterScopeMatch } from "#policy/rulesStorage.js";
9
+ const RULE_COUNT = 10_000;
10
+ const QUERY_COUNT = 200;
11
+ const P50_LATENCY_MS = 80;
12
+ const P99_LATENCY_MS = 250;
13
+ const buildBenchmarkRule = (i) => {
14
+ const bucket = i % 10;
15
+ const ja4 = `t13d_bench_${(i % 50).toString().padStart(3, "0")}`;
16
+ const asn = 1000 + (i % 500);
17
+ const description = `bench-rule-${i}`;
18
+ if (bucket < 7) {
19
+ const variant = i % 3;
20
+ if (variant === 0)
21
+ return { type: AccessPolicyType.Block, description, ja4Hash: ja4 };
22
+ if (variant === 1)
23
+ return {
24
+ type: AccessPolicyType.Block,
25
+ description,
26
+ asn,
27
+ };
28
+ return {
29
+ type: AccessPolicyType.Restrict,
30
+ description,
31
+ countryCode: `C${i % 30}`,
32
+ };
33
+ }
34
+ if (bucket < 9) {
35
+ return {
36
+ type: AccessPolicyType.Block,
37
+ description,
38
+ clientId: `client${i % 20}`,
39
+ ja4Hash: ja4,
40
+ ...(i % 2 === 0 && { userAgentHash: `ua${i % 100}` }),
41
+ };
42
+ }
43
+ return {
44
+ type: AccessPolicyType.Block,
45
+ description,
46
+ clientId: `client${i % 20}`,
47
+ ja4Hash: ja4,
48
+ userAgentHash: `ua${i % 100}`,
49
+ headersHash: `hh${i % 200}`,
50
+ countryCode: `C${i % 30}`,
51
+ };
52
+ };
53
+ const buildBenchmarkRequest = (i) => {
54
+ return {
55
+ clientId: `client${i % 20}`,
56
+ scope: {
57
+ ja4Hash: `t13d_bench_${(i % 50).toString().padStart(3, "0")}`,
58
+ userAgentHash: `ua${i % 100}`,
59
+ headersHash: `hh${i % 200}`,
60
+ countryCode: `C${i % 30}`,
61
+ asn: 1000 + (i % 500),
62
+ },
63
+ };
64
+ };
65
+ const percentile = (sortedMs, q) => {
66
+ if (sortedMs.length === 0)
67
+ return 0;
68
+ const idx = Math.min(sortedMs.length - 1, Math.floor((sortedMs.length - 1) * q));
69
+ const v = sortedMs[idx];
70
+ return v ?? 0;
71
+ };
72
+ describe("redisRulesReader ranked-path benchmark", () => {
73
+ let redisConnection;
74
+ let redisClient;
75
+ let reader;
76
+ const mockLogger = new Proxy({}, {
77
+ get: () => () => { },
78
+ });
79
+ const seededKeys = [];
80
+ beforeAll(async () => {
81
+ redisConnection = createTestRedisConnection();
82
+ redisClient = await setupRedisIndex(redisConnection, accessRulesRedisIndex, mockLogger).getClient();
83
+ reader = new RedisRulesReader(redisClient, mockLogger);
84
+ const rules = [];
85
+ for (let i = 0; i < RULE_COUNT; i++) {
86
+ rules.push(buildBenchmarkRule(i));
87
+ }
88
+ await executeBatchesSequentially(chunkIntoBatches(rules, 1000), async (batch) => {
89
+ const multi = redisClient.multi();
90
+ for (const rule of batch) {
91
+ const key = getAccessRuleRedisKey(rule);
92
+ seededKeys.push(key);
93
+ multi.hSet(key, getRedisRuleValue(rule));
94
+ }
95
+ await multi.exec();
96
+ });
97
+ const indexInfo = await redisClient.ft.info(ACCESS_RULES_REDIS_INDEX_NAME);
98
+ expect(indexInfo.num_docs).toBeGreaterThan(RULE_COUNT * 0.9);
99
+ }, 120_000);
100
+ afterAll(async () => {
101
+ await executeBatchesSequentially(chunkIntoBatches(seededKeys, 1000), async (batch) => {
102
+ const multi = redisClient.multi();
103
+ for (const key of batch) {
104
+ multi.del(key);
105
+ }
106
+ await multi.exec();
107
+ });
108
+ });
109
+ test(`ranked findRules p50/p99 over ${QUERY_COUNT} queries against ${RULE_COUNT} rules`, async () => {
110
+ await reader.findRules({
111
+ policyScope: { clientId: "warmup" },
112
+ policyScopeMatch: FilterScopeMatch.Greedy,
113
+ userScope: { ja4Hash: "warmup" },
114
+ userScopeMatch: FilterScopeMatch.Greedy,
115
+ }, true);
116
+ const samples = [];
117
+ for (let i = 0; i < QUERY_COUNT; i++) {
118
+ const { clientId, scope } = buildBenchmarkRequest(i);
119
+ const start = performance.now();
120
+ await reader.findRules({
121
+ policyScope: { clientId },
122
+ policyScopeMatch: FilterScopeMatch.Greedy,
123
+ userScope: scope,
124
+ userScopeMatch: FilterScopeMatch.Greedy,
125
+ }, true);
126
+ samples.push(performance.now() - start);
127
+ }
128
+ samples.sort((a, b) => a - b);
129
+ const p50 = percentile(samples, 0.5);
130
+ const p99 = percentile(samples, 0.99);
131
+ const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
132
+ console.log(`ranked findRules: avg=${avg.toFixed(2)}ms p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms n=${samples.length}`);
133
+ expect(p50).toBeLessThan(P50_LATENCY_MS);
134
+ expect(p99).toBeLessThan(P99_LATENCY_MS);
135
+ }, 120_000);
136
+ test("ranked findRules result size is bounded by SERVER_SIDE_RANK_TOP_N", async () => {
137
+ const { clientId, scope } = buildBenchmarkRequest(0);
138
+ const results = await reader.findRules({
139
+ policyScope: { clientId },
140
+ policyScopeMatch: FilterScopeMatch.Greedy,
141
+ userScope: scope,
142
+ userScopeMatch: FilterScopeMatch.Greedy,
143
+ }, true);
144
+ expect(results.length).toBeLessThanOrEqual(20);
145
+ expect(results.length).toBeGreaterThan(0);
146
+ }, 60_000);
147
+ test("HGETALL-free: ranked path returns full rules without per-rule fanout", async () => {
148
+ const { clientId, scope } = buildBenchmarkRequest(0);
149
+ const ranked = await reader.findRules({
150
+ policyScope: { clientId },
151
+ policyScopeMatch: FilterScopeMatch.Greedy,
152
+ userScope: scope,
153
+ userScopeMatch: FilterScopeMatch.Greedy,
154
+ }, true);
155
+ expect(ranked.length).toBeGreaterThan(0);
156
+ for (const rule of ranked) {
157
+ expect(rule.type).toBeDefined();
158
+ }
159
+ }, 60_000);
160
+ test("ranked findRules returns rules in specificity-descending order", async () => {
161
+ const { clientId, scope } = buildBenchmarkRequest(9);
162
+ const results = await reader.findRules({
163
+ policyScope: { clientId },
164
+ policyScopeMatch: FilterScopeMatch.Greedy,
165
+ userScope: scope,
166
+ userScopeMatch: FilterScopeMatch.Greedy,
167
+ }, true);
168
+ if (results.length === 0)
169
+ return;
170
+ const top = results[0];
171
+ if (!top)
172
+ return;
173
+ const countFields = (r) => (r.userId ? 1 : 0) +
174
+ (r.ja4Hash ? 1 : 0) +
175
+ (r.headersHash ? 1 : 0) +
176
+ (r.userAgentHash ? 1 : 0) +
177
+ (r.headHash ? 1 : 0) +
178
+ (r.coords ? 1 : 0) +
179
+ (r.countryCode ? 1 : 0) +
180
+ (r.asn !== undefined ? 1 : 0) +
181
+ (r.clientId ? 1 : 0) +
182
+ (r.numericIp !== undefined || r.numericIpMaskMin !== undefined ? 1 : 0);
183
+ const topScore = countFields(top);
184
+ for (const r of results) {
185
+ expect(countFields(r)).toBeLessThanOrEqual(topScore);
186
+ }
187
+ }, 60_000);
188
+ });
189
+ //# sourceMappingURL=redisRulesReaderRank.benchmark.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redisRulesReaderRank.benchmark.integration.test.js","sourceRoot":"","sources":["../../../src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/user-access-policy",
3
- "version": "3.9.1",
3
+ "version": "3.10.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^24",
@@ -43,13 +43,13 @@
43
43
  "test": "npm run test:unit && npm run test:integration"
44
44
  },
45
45
  "dependencies": {
46
- "@prosopo/api": "3.4.11",
47
- "@prosopo/api-route": "2.6.46",
48
- "@prosopo/common": "3.1.38",
49
- "@prosopo/logger": "1.0.2",
50
- "@prosopo/redis-client": "1.0.23",
51
- "@prosopo/types": "4.4.1",
52
- "@prosopo/util": "3.2.15",
46
+ "@prosopo/api": "3.4.13",
47
+ "@prosopo/api-route": "2.6.47",
48
+ "@prosopo/common": "3.1.39",
49
+ "@prosopo/logger": "1.0.3",
50
+ "@prosopo/redis-client": "1.0.24",
51
+ "@prosopo/types": "4.6.0",
52
+ "@prosopo/util": "3.3.0",
53
53
  "@redis/search": "5.0.0",
54
54
  "cidr-calc": "1.0.4",
55
55
  "dotenv": "16.4.5",
@@ -16,15 +16,21 @@ import * as util from "node:util";
16
16
  import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
17
17
  import type { Logger } from "@prosopo/logger";
18
18
  import type { RedisClientType } from "redis";
19
- import { getRulesRedisQuery } from "#policy/redis/reader/redisRulesQuery.js";
19
+ import {
20
+ REDIS_QUERY_DIALECT,
21
+ getRulesRedisQuery,
22
+ } from "#policy/redis/reader/redisRulesQuery.js";
20
23
  import {
21
24
  REDIS_BATCH_SIZE,
22
25
  fetchRedisHashRecords,
23
26
  getMissingRedisKeys,
24
27
  parseRedisRecords,
25
28
  } from "#policy/redis/redisClient.js";
26
- import { ACCESS_RULE_REDIS_KEY_PREFIX } from "#policy/redis/redisRuleIndex.js";
27
- import type { AccessRule } from "#policy/rule.js";
29
+ import {
30
+ ACCESS_RULES_REDIS_INDEX_NAME,
31
+ ACCESS_RULE_REDIS_KEY_PREFIX,
32
+ } from "#policy/redis/redisRuleIndex.js";
33
+ import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
28
34
  import { accessRuleInput } from "#policy/ruleInput/ruleInput.js";
29
35
  import type {
30
36
  AccessRuleEntry,
@@ -33,14 +39,90 @@ import type {
33
39
  } from "#policy/rulesStorage.js";
34
40
  import { aggregateRedisKeys } from "./redisAggregate.js";
35
41
 
36
- // Verify-time lookup safety cap. The greedy `userScopeMatch` OR-query can match
37
- // thousands of candidates on bot-attack-scale accounts; we still need to bring
38
- // every candidate back so JS-side specificity ranking can pick the right rule,
39
- // but a hard cap prevents a pathological match (e.g. an attacker crafting a
40
- // userScope that hits the entire account's rule index) from spiralling the
41
- // per-request cost. 10× the page size leaves comfortable headroom over the
42
- // observed worst case (~2k candidates).
43
- export const FIND_RULES_MAX_CANDIDATES = REDIS_BATCH_SIZE * 10;
42
+ // Server-side specificity ranking config.
43
+ //
44
+ // Strict-match findRules (matchingFieldsOnly=true) used to pull every
45
+ // matching rule's hash into Node and sort in JS. With the greedy regression
46
+ // in #2689 this hauled ~1190 hashes per request and pegged provider CPU.
47
+ //
48
+ // The new path lets RediSearch compute specificity itself: one
49
+ // FT.AGGREGATE that filters via the strict AND-of-disjunctions query,
50
+ // counts populated rule fields with APPLY+exists(), sorts by that
51
+ // derived score DESC, and returns only the top-N candidates. Node
52
+ // receives at most TOP_N small records, no HGETALL fanout, no JS rank.
53
+ //
54
+ // TOP_N >> 1 leaves headroom for the deferToVerify filter that runs
55
+ // post-aggregate (a Block rule with deferToVerify is skipped in the
56
+ // blockMiddleware path, so we need a few alternates ready). 20 covers
57
+ // the worst observed concentration of deferToVerify rules per scope by
58
+ // comfortable margin while keeping payload tiny.
59
+ export const SERVER_SIDE_RANK_TOP_N = 20;
60
+
61
+ // Safety cap for the greedy (admin/internal) path. Generous because
62
+ // admin tooling depends on the full result set; not on the hot
63
+ // per-request path.
64
+ const GREEDY_MAX_CANDIDATES = REDIS_BATCH_SIZE * 10;
65
+
66
+ // Fields that contribute one specificity point each. Mirrors
67
+ // SCALAR_USER_SCOPE_FIELDS + clientId + ip-constraint in
68
+ // blacklistRequestInspector.ruleSpecificity. numericIp and the
69
+ // numericIpMaskMin range are mutually exclusive on a rule (see
70
+ // ruleHasIpConstraint), so `exists(@numericIp) + exists(@numericIpMaskMin)`
71
+ // contributes 1 in practice — never 2 — when applied to writer-produced
72
+ // rules.
73
+ const SPECIFICITY_EXPR = [
74
+ "exists(@clientId)",
75
+ "exists(@userId)",
76
+ "exists(@ja4Hash)",
77
+ "exists(@headersHash)",
78
+ "exists(@userAgentHash)",
79
+ "exists(@headHash)",
80
+ "exists(@coords)",
81
+ "exists(@countryCode)",
82
+ "exists(@asn)",
83
+ "exists(@numericIp)",
84
+ "exists(@numericIpMaskMin)",
85
+ ].join(" + ");
86
+
87
+ // Block outranks Restrict on equal specificity (defensive: a request
88
+ // that would match a Block rule must never be downgraded). Note the
89
+ // string literal is lowercase because AccessPolicyType.Block = "block"
90
+ // and getRedisRuleValue serialises via String().
91
+ const SEVERITY_EXPR = `(@type == "${AccessPolicyType.Block}")`;
92
+
93
+ // Final rank: specificity weighted, severity as tiebreaker on equal
94
+ // specificity. spec*2 + sev fits both into a single sortable float.
95
+ const RANK_EXPR = "(@_spec * 2) + @_sev";
96
+
97
+ // Every field referenced by SPECIFICITY_EXPR/SEVERITY_EXPR, plus the
98
+ // fields needed to reconstruct an AccessRule downstream. APPLY can only
99
+ // see fields explicitly loaded into the aggregate pipeline, so this
100
+ // list must include every rule attribute the parser cares about.
101
+ const RULE_LOAD_FIELDS = [
102
+ "@__key",
103
+ "@type",
104
+ "@captchaType",
105
+ "@description",
106
+ "@solvedImagesCount",
107
+ "@imageThreshold",
108
+ "@powDifficulty",
109
+ "@unsolvedImagesCount",
110
+ "@frictionlessScore",
111
+ "@deferToVerify",
112
+ "@clientId",
113
+ "@groupId",
114
+ "@userId",
115
+ "@ja4Hash",
116
+ "@headersHash",
117
+ "@userAgentHash",
118
+ "@headHash",
119
+ "@coords",
120
+ "@countryCode",
121
+ "@asn",
122
+ "@numericIp",
123
+ "@numericIpMaskMin",
124
+ "@numericIpMaskMax",
125
+ ] as const;
44
126
 
45
127
  export class RedisRulesReader implements AccessRulesReader {
46
128
  constructor(
@@ -87,23 +169,107 @@ export class RedisRulesReader implements AccessRulesReader {
87
169
  return [];
88
170
  }
89
171
 
172
+ // Hot path: strict-match callers (blockMiddleware /
173
+ // checkForHardBlock) get server-side specificity ranking via
174
+ // FT.AGGREGATE — Redis returns the top N candidates already
175
+ // sorted, no HGETALL fanout. See SPECIFICITY_EXPR / RANK_EXPR
176
+ // above for the score definition.
177
+ if (matchingFieldsOnly) {
178
+ return this.findRulesRanked(filter, query);
179
+ }
180
+
181
+ // Admin / internal callers (greedy mode): keep the FT.SEARCH +
182
+ // HGETALL fanout path. Cap at REDIS_BATCH_SIZE; truncation past
183
+ // that point is the known liability tracked in #2689.
184
+ return this.findRulesGreedy(filter, query);
185
+ }
186
+
187
+ private async findRulesRanked(
188
+ filter: AccessRulesFilter,
189
+ query: string,
190
+ ): Promise<AccessRule[]> {
191
+ try {
192
+ const reply = await this.client.ft.aggregate(
193
+ ACCESS_RULES_REDIS_INDEX_NAME,
194
+ query,
195
+ {
196
+ DIALECT: REDIS_QUERY_DIALECT,
197
+ LOAD: [...RULE_LOAD_FIELDS],
198
+ STEPS: [
199
+ { type: "APPLY", expression: SPECIFICITY_EXPR, AS: "_spec" },
200
+ { type: "APPLY", expression: SEVERITY_EXPR, AS: "_sev" },
201
+ { type: "APPLY", expression: RANK_EXPR, AS: "_rank" },
202
+ {
203
+ type: "SORTBY",
204
+ BY: [{ BY: "@_rank", DIRECTION: "DESC" }],
205
+ // SORTBY MAX would let RediSearch keep only the
206
+ // top-N in a bounded heap rather than fully
207
+ // sorting, but the @redis/search client
208
+ // serialises MAX *before* DESC and RediSearch
209
+ // rejects that order ("MISSING ASC or DESC
210
+ // after sort field (MAX)"). LIMIT below still
211
+ // trims; SORTBY does a full sort. Acceptable
212
+ // because the strict-match filter already
213
+ // keeps the candidate set small in practice.
214
+ },
215
+ { type: "LIMIT", from: 0, size: SERVER_SIDE_RANK_TOP_N },
216
+ ],
217
+ },
218
+ );
219
+
220
+ if (reply.results.length === 0) {
221
+ return [];
222
+ }
223
+
224
+ this.logger.debug(() => ({
225
+ msg: "Executed ranked search query",
226
+ data: {
227
+ inspect: util.inspect(
228
+ {
229
+ filter: filter,
230
+ foundCount: reply.results.length,
231
+ query: query,
232
+ },
233
+ { depth: null },
234
+ ),
235
+ },
236
+ }));
237
+
238
+ // FT.AGGREGATE results include the derived _spec/_sev/_rank
239
+ // fields and the loaded rule fields. parseRedisRecords runs
240
+ // through zod which ignores the derived fields. __key is
241
+ // also passed through but unused downstream.
242
+ return parseRedisRecords(reply.results, accessRuleInput, this.logger);
243
+ } catch (e) {
244
+ this.logger.error(() => ({
245
+ err: e,
246
+ data: {
247
+ inspect: util.inspect({ query, filter }, { depth: null }),
248
+ },
249
+ msg: "failed to execute ranked search query",
250
+ }));
251
+
252
+ return [];
253
+ }
254
+ }
255
+
256
+ // Used only by admin / internal callers (matchingFieldsOnly=false).
257
+ // Not on the per-request blockMiddleware path any more, so the CPU
258
+ // cost of the HGETALL fanout is acceptable. Same shape as #2689's
259
+ // aggregate-with-cursor: returns every candidate up to
260
+ // GREEDY_MAX_CANDIDATES so we don't silently drop matches that
261
+ // admin tools depend on (e.g. ASN-wide rule listings).
262
+ private async findRulesGreedy(
263
+ filter: AccessRulesFilter,
264
+ query: string,
265
+ ): Promise<AccessRule[]> {
90
266
  try {
91
- // FT.AGGREGATE WITHCURSOR (via aggregateRedisKeys) instead of
92
- // FT.SEARCH. FT.SEARCH's LIMIT caps the candidate set at
93
- // REDIS_BATCH_SIZE (1000), silently dropping anything past that.
94
- // Under the greedy `userScopeMatch` mode (#2657), the OR-of-fields
95
- // query for a popular ja4 hash returns thousands of candidates —
96
- // matches sitting past offset 1000 (block rules emitted by
97
- // less-frequent detectors like SUDDEN_VOLUME_INCREASE) were lost,
98
- // so verify-time ranking only saw the high-volume rules and the
99
- // most-specific block rule for the request never reached the
100
- // JS-side specificity sort.
101
267
  const ruleKeys = await aggregateRedisKeys(
102
268
  this.client,
103
269
  query,
104
270
  this.logger,
105
271
  undefined,
106
- FIND_RULES_MAX_CANDIDATES,
272
+ GREEDY_MAX_CANDIDATES,
107
273
  );
108
274
 
109
275
  if (ruleKeys.length === 0) {
@@ -111,7 +277,7 @@ export class RedisRulesReader implements AccessRulesReader {
111
277
  }
112
278
 
113
279
  this.logger.debug(() => ({
114
- msg: "Executed search query",
280
+ msg: "Executed greedy search query",
115
281
  data: {
116
282
  inspect: util.inspect(
117
283
  {
@@ -139,17 +305,9 @@ export class RedisRulesReader implements AccessRulesReader {
139
305
  this.logger.error(() => ({
140
306
  err: e,
141
307
  data: {
142
- inspect: util.inspect(
143
- {
144
- query: query,
145
- filter: filter,
146
- },
147
- {
148
- depth: null,
149
- },
150
- ),
308
+ inspect: util.inspect({ query, filter }, { depth: null }),
151
309
  },
152
- msg: "failed to execute search query",
310
+ msg: "failed to execute greedy search query",
153
311
  }));
154
312
 
155
313
  return [];