@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.
- 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 +16 -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 +3 -3
- 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,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.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": "^24",
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"test": "npm run test:unit && npm run test:integration"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@prosopo/api": "3.4.
|
|
46
|
+
"@prosopo/api": "3.4.12",
|
|
47
47
|
"@prosopo/api-route": "2.6.46",
|
|
48
48
|
"@prosopo/common": "3.1.38",
|
|
49
49
|
"@prosopo/logger": "1.0.2",
|
|
50
50
|
"@prosopo/redis-client": "1.0.23",
|
|
51
|
-
"@prosopo/types": "4.
|
|
51
|
+
"@prosopo/types": "4.5.0",
|
|
52
52
|
"@prosopo/util": "3.2.15",
|
|
53
53
|
"@redis/search": "5.0.0",
|
|
54
54
|
"cidr-calc": "1.0.4",
|
|
@@ -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 {
|
|
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 {
|
|
27
|
-
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
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 [];
|