@prosopo/user-access-policy 3.12.28 → 3.12.30
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 +8 -8
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +16 -0
- package/dist/cjs/redis/reader/redisRulesQuery.cjs +3 -1
- package/dist/cjs/redis/reader/redisRulesReader.cjs +4 -1
- package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +31 -5
- package/dist/cjs/redis/redisRuleIndex.cjs +4 -0
- package/dist/cjs/ruleInput/ruleInput.cjs +2 -1
- package/dist/redis/reader/redisRulesQuery.d.ts +1 -0
- package/dist/redis/reader/redisRulesQuery.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesQuery.js +3 -2
- package/dist/redis/reader/redisRulesQuery.js.map +1 -1
- package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesReader.js +4 -1
- package/dist/redis/reader/redisRulesReader.js.map +1 -1
- package/dist/redis/reader/redisRulesSplitQuery.d.ts +1 -0
- package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
- package/dist/redis/reader/redisRulesSplitQuery.js +32 -6
- package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
- package/dist/redis/redisRuleIndex.d.ts.map +1 -1
- package/dist/redis/redisRuleIndex.js +4 -0
- package/dist/redis/redisRuleIndex.js.map +1 -1
- package/dist/ruleInput/ruleInput.d.ts +3 -0
- package/dist/ruleInput/ruleInput.d.ts.map +1 -1
- package/dist/ruleInput/ruleInput.js +2 -1
- package/dist/ruleInput/ruleInput.js.map +1 -1
- package/dist/rulesStorage.d.ts +1 -0
- package/dist/rulesStorage.d.ts.map +1 -1
- package/dist/tests/blockingRuleShapes.unit.test.d.ts +2 -0
- package/dist/tests/blockingRuleShapes.unit.test.d.ts.map +1 -0
- package/dist/tests/blockingRuleShapes.unit.test.js +176 -0
- package/dist/tests/blockingRuleShapes.unit.test.js.map +1 -0
- package/dist/tests/redis/deferredPoolClause.benchmark.d.ts +2 -0
- package/dist/tests/redis/deferredPoolClause.benchmark.d.ts.map +1 -0
- package/dist/tests/redis/deferredPoolClause.benchmark.js +175 -0
- package/dist/tests/redis/deferredPoolClause.benchmark.js.map +1 -0
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +33 -0
- package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
- package/package.json +3 -3
- package/src/redis/reader/redisRulesQuery.ts +24 -1
- package/src/redis/reader/redisRulesReader.ts +1 -0
- package/src/redis/reader/redisRulesSplitQuery.ts +51 -14
- package/src/redis/redisRuleIndex.ts +7 -0
- package/src/ruleInput/ruleInput.ts +1 -0
- package/src/rulesStorage.ts +9 -0
- package/src/tests/blockingRuleShapes.unit.test.ts +244 -0
- package/src/tests/redis/deferredPoolClause.benchmark.ts +249 -0
- package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +73 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -12,11 +12,8 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
GLOBAL_CLIENT_SCOPE_SENTINEL,
|
|
18
|
-
type UserScope,
|
|
19
|
-
} from "#policy/rule.js";
|
|
15
|
+
import { getBlockPoolClause } from "#policy/redis/reader/redisRulesQuery.js";
|
|
16
|
+
import { GLOBAL_CLIENT_SCOPE_SENTINEL, type UserScope } from "#policy/rule.js";
|
|
20
17
|
|
|
21
18
|
// Escapes special characters in Redis TAG queries. Mirrors the escape
|
|
22
19
|
// function in redisRulesQuery.ts — kept local to avoid coupling the two
|
|
@@ -113,6 +110,9 @@ type SubQuery = {
|
|
|
113
110
|
*
|
|
114
111
|
* `blockOnly` narrows the candidate set to rules with `type:{block}` —
|
|
115
112
|
* used by the hard-block middleware which never needs Restrict rules.
|
|
113
|
+
* `includeDeferred` widens that pool back out to
|
|
114
|
+
* `(@type:{block} | @deferToVerify:{true})` for the verify-time
|
|
115
|
+
* hard-block lookup, where a deferred Restrict is a valid block.
|
|
116
116
|
* Every other caller passes false so both Block and Restrict rules are
|
|
117
117
|
* fetched; the JS-side ranker picks the right one via specificity +
|
|
118
118
|
* severity. Merging the block and restrict paths behind one split-query
|
|
@@ -124,14 +124,51 @@ type SubQuery = {
|
|
|
124
124
|
export const buildScopedRulesSubQueries = (
|
|
125
125
|
userScope: UserScope,
|
|
126
126
|
clientId: string | undefined,
|
|
127
|
-
options: { blockOnly?: boolean } = {},
|
|
127
|
+
options: { blockOnly?: boolean; includeDeferred?: boolean } = {},
|
|
128
128
|
): SubQuery[] => {
|
|
129
|
-
const typeClause = options.blockOnly
|
|
130
|
-
? `@type:{${AccessPolicyType.Block}} `
|
|
131
|
-
: "";
|
|
132
129
|
const scopeClause = buildScopeClause(clientId);
|
|
133
|
-
const prefix = `${typeClause}${scopeClause}`;
|
|
134
130
|
|
|
131
|
+
// `includeDeferred` emits the probe set twice — once for plain hard
|
|
132
|
+
// blocks, once for deferred rules — rather than widening one type
|
|
133
|
+
// clause to `(@type:{block} | @deferToVerify:{true})`.
|
|
134
|
+
//
|
|
135
|
+
// Widening merges both populations into a single probe, so they share
|
|
136
|
+
// one SPLIT_MAX_CANDIDATES_PER_SUB budget and a dense deferred cohort
|
|
137
|
+
// can truncate hard blocks out of the candidate set. Measured on a
|
|
138
|
+
// scope with 400 block + 400 deferred rules on one ja4Hash: the merged
|
|
139
|
+
// probe returns 800 against a cap of 500, so 300 candidates are
|
|
140
|
+
// dropped with no ordering guarantee about which. That is precisely
|
|
141
|
+
// the crowding `blockOnly` was introduced to prevent.
|
|
142
|
+
//
|
|
143
|
+
// Two disjoint probe sets each get their own budget, so a hard block
|
|
144
|
+
// is never displaced by a deferred rule. The pools are disjoint by
|
|
145
|
+
// construction (the block probe excludes deferred), so the union
|
|
146
|
+
// double-counts nothing.
|
|
147
|
+
// Probe `kind` is only namespaced when there are genuinely two pools;
|
|
148
|
+
// single-pool callers keep the original unprefixed labels.
|
|
149
|
+
const typePools: Array<{ tag: string; clause: string }> = options.blockOnly
|
|
150
|
+
? options.includeDeferred === true
|
|
151
|
+
? [
|
|
152
|
+
{ tag: "block", clause: `${getBlockPoolClause(false)} ` },
|
|
153
|
+
{ tag: "deferred", clause: "@deferToVerify:{true} " },
|
|
154
|
+
]
|
|
155
|
+
: [{ tag: "", clause: `${getBlockPoolClause(false)} ` }]
|
|
156
|
+
: [{ tag: "", clause: "" }];
|
|
157
|
+
|
|
158
|
+
const subQueries: SubQuery[] = [];
|
|
159
|
+
for (const pool of typePools) {
|
|
160
|
+
const prefix = `${pool.clause}${scopeClause}`;
|
|
161
|
+
const label = pool.tag === "" ? "" : `${pool.tag}:`;
|
|
162
|
+
subQueries.push(...buildProbes(userScope, prefix, label));
|
|
163
|
+
}
|
|
164
|
+
return subQueries;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const buildProbes = (
|
|
168
|
+
userScope: UserScope,
|
|
169
|
+
prefix: string,
|
|
170
|
+
label: string,
|
|
171
|
+
): SubQuery[] => {
|
|
135
172
|
const subQueries: SubQuery[] = [];
|
|
136
173
|
|
|
137
174
|
// One probe per populated scalar user-scope field. Each uses that
|
|
@@ -143,7 +180,7 @@ export const buildScopedRulesSubQueries = (
|
|
|
143
180
|
continue;
|
|
144
181
|
}
|
|
145
182
|
subQueries.push({
|
|
146
|
-
kind:
|
|
183
|
+
kind: `${label}field:${field}`,
|
|
147
184
|
query: `${prefix} ${clause}`,
|
|
148
185
|
});
|
|
149
186
|
}
|
|
@@ -158,11 +195,11 @@ export const buildScopedRulesSubQueries = (
|
|
|
158
195
|
const requestIp = userScope.numericIp;
|
|
159
196
|
if (requestIp !== undefined) {
|
|
160
197
|
subQueries.push({
|
|
161
|
-
kind:
|
|
198
|
+
kind: `${label}ip:exact`,
|
|
162
199
|
query: `${prefix} @numericIp:[${requestIp} ${requestIp}]`,
|
|
163
200
|
});
|
|
164
201
|
subQueries.push({
|
|
165
|
-
kind:
|
|
202
|
+
kind: `${label}ip:mask`,
|
|
166
203
|
query: `${prefix} @numericIpMaskMin:[-inf ${requestIp}] @numericIpMaskMax:[${requestIp} +inf]`,
|
|
167
204
|
});
|
|
168
205
|
}
|
|
@@ -175,7 +212,7 @@ export const buildScopedRulesSubQueries = (
|
|
|
175
212
|
(field) => `ismissing(@${field})`,
|
|
176
213
|
).join(" ");
|
|
177
214
|
subQueries.push({
|
|
178
|
-
kind:
|
|
215
|
+
kind: `${label}no-user-scope`,
|
|
179
216
|
query: `${prefix} ${noScopeIsmissing}`,
|
|
180
217
|
});
|
|
181
218
|
|
|
@@ -73,6 +73,13 @@ export const accessRuleRedisSchema: RediSearchSchema = {
|
|
|
73
73
|
// Restrict / routing-Block populations push the hard-block rules out
|
|
74
74
|
// of the top-N candidate set and the lookup silently misses them.
|
|
75
75
|
type: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
76
|
+
// Indexed so `checkForHardBlock` can widen its Block-only pool to
|
|
77
|
+
// `(@type:{block} | @deferToVerify:{true})`. Deferred rules are
|
|
78
|
+
// skipped at request time and enforced at verify, so a deferred
|
|
79
|
+
// Restrict is a legitimate hard block — without this field in the
|
|
80
|
+
// index it can't be selected server-side and never fires.
|
|
81
|
+
// Stored by `getRedisRuleValue` as the string "true"/"false".
|
|
82
|
+
deferToVerify: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
76
83
|
} satisfies Keys<AccessRule>;
|
|
77
84
|
|
|
78
85
|
export const ACCESS_RULES_REDIS_INDEX_NAME = "index:user-access-rules";
|
|
@@ -98,6 +98,7 @@ export const accessRulesFilterInput = z.object({
|
|
|
98
98
|
.default(FilterScopeMatch.Exact),
|
|
99
99
|
groupId: z.string().optional(),
|
|
100
100
|
blockOnly: z.boolean().optional(),
|
|
101
|
+
includeDeferred: z.boolean().optional(),
|
|
101
102
|
} satisfies AllKeys<AccessRulesFilterInput>);
|
|
102
103
|
|
|
103
104
|
export const getAccessRuleFiltersFromInput = (
|
package/src/rulesStorage.ts
CHANGED
|
@@ -42,6 +42,15 @@ export type AccessRulesFilter = {
|
|
|
42
42
|
* and the lookup misses them.
|
|
43
43
|
*/
|
|
44
44
|
blockOnly?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Widen a `blockOnly` pool to also admit deferred rules of any type.
|
|
47
|
+
* A `deferToVerify` rule is skipped at request time and enforced at
|
|
48
|
+
* verify, so it can legitimately be a Restrict — `blockOnly` alone
|
|
49
|
+
* would filter it out of the verify-time lookup and the rule would
|
|
50
|
+
* never fire. Only `checkForHardBlock` sets this; the request-time
|
|
51
|
+
* middleware keeps the narrow Block-only pool.
|
|
52
|
+
*/
|
|
53
|
+
includeDeferred?: boolean;
|
|
45
54
|
};
|
|
46
55
|
|
|
47
56
|
export type AccessRuleEntry = {
|
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
// Characterisation tests for the two blocking rule shapes as they pass
|
|
16
|
+
// through the write-side zod types:
|
|
17
|
+
//
|
|
18
|
+
// 1. plain Block — rejects at request time
|
|
19
|
+
// 2. Block + deferToVerify — passes request time, rejects at verify
|
|
20
|
+
//
|
|
21
|
+
// These pin down what each shape may carry, and what survives the write
|
|
22
|
+
// path into storage. Written against the schemas the rule-sync API
|
|
23
|
+
// actually validates with, so a change in either direction fails here
|
|
24
|
+
// first rather than as a 400 in production.
|
|
25
|
+
|
|
26
|
+
import type { Logger } from "@prosopo/logger";
|
|
27
|
+
import { CaptchaType } from "@prosopo/types";
|
|
28
|
+
import { describe, expect, it, vi } from "vitest";
|
|
29
|
+
import { InsertRulesEndpoint } from "#policy/api/write/insertRules.js";
|
|
30
|
+
import { AccessPolicyType } from "#policy/rule.js";
|
|
31
|
+
import {
|
|
32
|
+
accessPolicyInput,
|
|
33
|
+
sanitizeAccessPolicy,
|
|
34
|
+
} from "#policy/ruleInput/policyInput.js";
|
|
35
|
+
import type { AccessRulesWriter } from "#policy/rulesStorage.js";
|
|
36
|
+
|
|
37
|
+
const makeMockLogger = (): Logger =>
|
|
38
|
+
({
|
|
39
|
+
trace: vi.fn(),
|
|
40
|
+
debug: vi.fn(),
|
|
41
|
+
info: vi.fn(),
|
|
42
|
+
warn: vi.fn(),
|
|
43
|
+
error: vi.fn(),
|
|
44
|
+
fatal: vi.fn(),
|
|
45
|
+
log: vi.fn(),
|
|
46
|
+
setLogLevel: vi.fn(),
|
|
47
|
+
getLogLevel: vi.fn().mockReturnValue("info"),
|
|
48
|
+
with: vi.fn().mockReturnThis(),
|
|
49
|
+
getScope: vi.fn().mockReturnValue("test"),
|
|
50
|
+
getPretty: vi.fn().mockReturnValue(false),
|
|
51
|
+
setPretty: vi.fn(),
|
|
52
|
+
getPrintStack: vi.fn().mockReturnValue(false),
|
|
53
|
+
setPrintStack: vi.fn(),
|
|
54
|
+
getFormat: vi.fn().mockReturnValue("json"),
|
|
55
|
+
setFormat: vi.fn(),
|
|
56
|
+
}) satisfies Logger;
|
|
57
|
+
|
|
58
|
+
const makeMockWriter = (): AccessRulesWriter =>
|
|
59
|
+
({
|
|
60
|
+
insertRules: vi.fn().mockResolvedValue(["id1"]),
|
|
61
|
+
}) as unknown as AccessRulesWriter;
|
|
62
|
+
|
|
63
|
+
const getInsertSchema = () =>
|
|
64
|
+
new InsertRulesEndpoint(
|
|
65
|
+
makeMockWriter(),
|
|
66
|
+
makeMockLogger(),
|
|
67
|
+
).getRequestArgsSchema();
|
|
68
|
+
|
|
69
|
+
describe("blocking rule shapes: plain Block", () => {
|
|
70
|
+
it("accepts a bare Block", () => {
|
|
71
|
+
const result = accessPolicyInput.safeParse({
|
|
72
|
+
type: AccessPolicyType.Block,
|
|
73
|
+
description: "bare block",
|
|
74
|
+
});
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects captchaType and solvedImagesCount together", () => {
|
|
79
|
+
const result = accessPolicyInput.safeParse({
|
|
80
|
+
type: AccessPolicyType.Block,
|
|
81
|
+
captchaType: CaptchaType.image,
|
|
82
|
+
solvedImagesCount: 2,
|
|
83
|
+
});
|
|
84
|
+
expect(result.success).toBe(false);
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
expect(result.error.issues.map((i) => i.path.at(-1)).sort()).toEqual([
|
|
87
|
+
"captchaType",
|
|
88
|
+
"solvedImagesCount",
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("strips both fields on the way into storage", () => {
|
|
94
|
+
expect(
|
|
95
|
+
sanitizeAccessPolicy({
|
|
96
|
+
type: AccessPolicyType.Block,
|
|
97
|
+
captchaType: CaptchaType.image,
|
|
98
|
+
solvedImagesCount: 2,
|
|
99
|
+
description: "legacy block",
|
|
100
|
+
}),
|
|
101
|
+
).toEqual({
|
|
102
|
+
type: AccessPolicyType.Block,
|
|
103
|
+
description: "legacy block",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("blocking rule shapes: Block + deferToVerify", () => {
|
|
109
|
+
it("accepts the flag on its own, and preserves it", () => {
|
|
110
|
+
const result = accessPolicyInput.safeParse({
|
|
111
|
+
type: AccessPolicyType.Block,
|
|
112
|
+
deferToVerify: true,
|
|
113
|
+
description: "deferred block",
|
|
114
|
+
});
|
|
115
|
+
expect(result.success).toBe(true);
|
|
116
|
+
if (result.success) {
|
|
117
|
+
expect(result.data.deferToVerify).toBe(true);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// The shape that 400s the rule-sync push. The refinement has no
|
|
122
|
+
// deferToVerify carve-out, so a deferred Block is held to the same
|
|
123
|
+
// rule as a plain one.
|
|
124
|
+
it("rejects captchaType and solvedImagesCount despite the flag", () => {
|
|
125
|
+
const result = accessPolicyInput.safeParse({
|
|
126
|
+
type: AccessPolicyType.Block,
|
|
127
|
+
deferToVerify: true,
|
|
128
|
+
captchaType: CaptchaType.image,
|
|
129
|
+
solvedImagesCount: 2,
|
|
130
|
+
});
|
|
131
|
+
expect(result.success).toBe(false);
|
|
132
|
+
if (!result.success) {
|
|
133
|
+
expect(result.error.issues.map((i) => i.path.at(-1)).sort()).toEqual([
|
|
134
|
+
"captchaType",
|
|
135
|
+
"solvedImagesCount",
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Even were the refinement to admit them, the sanitiser drops them
|
|
141
|
+
// before storage — the flag does not exempt a Block here either.
|
|
142
|
+
it("strips both fields on the way into storage", () => {
|
|
143
|
+
expect(
|
|
144
|
+
sanitizeAccessPolicy({
|
|
145
|
+
type: AccessPolicyType.Block,
|
|
146
|
+
deferToVerify: true,
|
|
147
|
+
captchaType: CaptchaType.image,
|
|
148
|
+
solvedImagesCount: 2,
|
|
149
|
+
description: "deferred block",
|
|
150
|
+
}),
|
|
151
|
+
).toEqual({
|
|
152
|
+
type: AccessPolicyType.Block,
|
|
153
|
+
deferToVerify: true,
|
|
154
|
+
description: "deferred block",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("round-trips the flag from its stored string form", () => {
|
|
159
|
+
const result = accessPolicyInput.safeParse({
|
|
160
|
+
type: AccessPolicyType.Block,
|
|
161
|
+
deferToVerify: "true",
|
|
162
|
+
});
|
|
163
|
+
expect(result.success).toBe(true);
|
|
164
|
+
if (result.success) {
|
|
165
|
+
expect(result.data.deferToVerify).toBe(true);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("blocking rule shapes: Restrict keeps its tuning fields", () => {
|
|
171
|
+
it("accepts captchaType and solvedImagesCount, with or without the flag", () => {
|
|
172
|
+
for (const deferToVerify of [undefined, true]) {
|
|
173
|
+
const result = accessPolicyInput.safeParse({
|
|
174
|
+
type: AccessPolicyType.Restrict,
|
|
175
|
+
captchaType: CaptchaType.image,
|
|
176
|
+
solvedImagesCount: 2,
|
|
177
|
+
...(deferToVerify === undefined ? {} : { deferToVerify }),
|
|
178
|
+
});
|
|
179
|
+
expect(result.success).toBe(true);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("survives the sanitiser intact", () => {
|
|
184
|
+
expect(
|
|
185
|
+
sanitizeAccessPolicy({
|
|
186
|
+
type: AccessPolicyType.Restrict,
|
|
187
|
+
deferToVerify: true,
|
|
188
|
+
captchaType: CaptchaType.image,
|
|
189
|
+
solvedImagesCount: 2,
|
|
190
|
+
}),
|
|
191
|
+
).toEqual({
|
|
192
|
+
type: AccessPolicyType.Restrict,
|
|
193
|
+
deferToVerify: true,
|
|
194
|
+
captchaType: CaptchaType.image,
|
|
195
|
+
solvedImagesCount: 2,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// The insert endpoint validates the batch as one `z.array`, so validity
|
|
201
|
+
// is all-or-nothing across the whole push.
|
|
202
|
+
describe("blocking rule shapes: batch validation", () => {
|
|
203
|
+
const validGroup = {
|
|
204
|
+
accessPolicy: {
|
|
205
|
+
type: AccessPolicyType.Block,
|
|
206
|
+
deferToVerify: true,
|
|
207
|
+
description: "deferred block",
|
|
208
|
+
},
|
|
209
|
+
userScopes: [{ ip: "1.1.1.1" }],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const invalidGroup = {
|
|
213
|
+
accessPolicy: {
|
|
214
|
+
type: AccessPolicyType.Block,
|
|
215
|
+
deferToVerify: true,
|
|
216
|
+
captchaType: CaptchaType.image,
|
|
217
|
+
solvedImagesCount: 2,
|
|
218
|
+
description: "deferred block with tuning fields",
|
|
219
|
+
},
|
|
220
|
+
userScopes: [{ ip: "2.2.2.2" }],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
it("accepts a batch of well-formed groups", () => {
|
|
224
|
+
const result = getInsertSchema().safeParse([validGroup, validGroup]);
|
|
225
|
+
expect(result.success).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("rejects the entire batch when a single group is malformed", () => {
|
|
229
|
+
const batch = [
|
|
230
|
+
...Array.from({ length: 20 }, () => validGroup),
|
|
231
|
+
invalidGroup,
|
|
232
|
+
...Array.from({ length: 20 }, () => validGroup),
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const result = getInsertSchema().safeParse(batch);
|
|
236
|
+
|
|
237
|
+
expect(result.success).toBe(false);
|
|
238
|
+
if (!result.success) {
|
|
239
|
+
// Every issue points at the one bad group; the other 40 are
|
|
240
|
+
// well-formed and are still rejected along with it.
|
|
241
|
+
expect(result.error.issues.map((i) => i.path[0])).toEqual([20, 20]);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
// Standalone benchmark for the hard-block candidate-pool clauses.
|
|
16
|
+
//
|
|
17
|
+
// Compares the previous single-field clause against the two new
|
|
18
|
+
// two-field clauses on the shapes they actually run in:
|
|
19
|
+
//
|
|
20
|
+
// baseline @type:{block} (old, both callers)
|
|
21
|
+
// middleware @type:{block} -@deferToVerify:{true} (new, request time)
|
|
22
|
+
// verify (@type:{block} | @deferToVerify:{true}) (new, verify)
|
|
23
|
+
//
|
|
24
|
+
// Also runs the MISGROUPED union as a control. `|` binds looser than the
|
|
25
|
+
// implicit AND, so `(@a)|(@b) scope ip` lets the left operand escape the
|
|
26
|
+
// scope/IP filters — it matches every Block rule in the index. That
|
|
27
|
+
// misgrouping is both wrong and ~7x slower, and it is invisible on a
|
|
28
|
+
// toy dataset; it only shows up at population scale, which is why this
|
|
29
|
+
// benchmark exists.
|
|
30
|
+
//
|
|
31
|
+
// Population mirrors redisRulesReaderLoad.benchmark.integration.test.ts
|
|
32
|
+
// (the 17k bulk-ban incident shape) plus a deferred-rule cohort.
|
|
33
|
+
//
|
|
34
|
+
// Run: npx tsx <this file> (needs redis-stack on localhost:6379)
|
|
35
|
+
|
|
36
|
+
import { createClient } from "redis";
|
|
37
|
+
|
|
38
|
+
const IP_RULE_COUNT = 17_000;
|
|
39
|
+
const CIDR_RULE_COUNT = 1_300;
|
|
40
|
+
const MIXED_RULE_COUNT = 1_000;
|
|
41
|
+
// Deferred rules as a share of the population. Production currently has
|
|
42
|
+
// ~380 deferred against ~4k live rules; 2_000 here is a deliberate
|
|
43
|
+
// over-weight so the union has real work to merge.
|
|
44
|
+
const DEFERRED_RULE_COUNT = 2_000;
|
|
45
|
+
|
|
46
|
+
const WARMUP = 50;
|
|
47
|
+
const ITERATIONS = 500;
|
|
48
|
+
|
|
49
|
+
const INDEX = "bench:idx";
|
|
50
|
+
const PREFIX = "bench:";
|
|
51
|
+
|
|
52
|
+
type Clause = { name: string; clause: string };
|
|
53
|
+
|
|
54
|
+
const CLAUSES: Clause[] = [
|
|
55
|
+
{ name: "baseline @type:{block}", clause: "@type:{block}" },
|
|
56
|
+
{
|
|
57
|
+
name: "middleware @type:{block} -@deferToVerify:{true}",
|
|
58
|
+
clause: "@type:{block} -@deferToVerify:{true}",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "verify (@type:{block} | @deferToVerify:{true})",
|
|
62
|
+
clause: "(@type:{block} | @deferToVerify:{true})",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "MISGROUPED (@type:{block})|(@deferToVerify:{true})",
|
|
66
|
+
clause: "(@type:{block})|(@deferToVerify:{true})",
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const ipv4 = (i: number): bigint => (10n << 24n) + BigInt(i);
|
|
71
|
+
|
|
72
|
+
const ipv4CidrRange = (i: number): { min: bigint; max: bigint } => {
|
|
73
|
+
const octet = i % 256;
|
|
74
|
+
const base = (192n << 24n) + (168n << 16n) + (BigInt(octet) << 8n);
|
|
75
|
+
return { min: base, max: base + 255n };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const percentile = (sorted: number[], p: number): number => {
|
|
79
|
+
const idx = Math.min(
|
|
80
|
+
sorted.length - 1,
|
|
81
|
+
Math.floor((p / 100) * sorted.length),
|
|
82
|
+
);
|
|
83
|
+
return sorted[idx] ?? 0;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const main = async (): Promise<void> => {
|
|
87
|
+
const client = createClient({
|
|
88
|
+
url: "redis://localhost:6379",
|
|
89
|
+
password: "root",
|
|
90
|
+
});
|
|
91
|
+
await client.connect();
|
|
92
|
+
|
|
93
|
+
await client.flushAll();
|
|
94
|
+
|
|
95
|
+
// Real index schema shape: type + deferToVerify as TAG INDEXMISSING,
|
|
96
|
+
// IP fields NUMERIC INDEXMISSING.
|
|
97
|
+
await client.sendCommand([
|
|
98
|
+
"FT.CREATE",
|
|
99
|
+
INDEX,
|
|
100
|
+
"ON",
|
|
101
|
+
"HASH",
|
|
102
|
+
"PREFIX",
|
|
103
|
+
"1",
|
|
104
|
+
PREFIX,
|
|
105
|
+
"SCHEMA",
|
|
106
|
+
"type",
|
|
107
|
+
"TAG",
|
|
108
|
+
"INDEXMISSING",
|
|
109
|
+
"deferToVerify",
|
|
110
|
+
"TAG",
|
|
111
|
+
"INDEXMISSING",
|
|
112
|
+
"clientId",
|
|
113
|
+
"TAG",
|
|
114
|
+
"INDEXMISSING",
|
|
115
|
+
"numericIp",
|
|
116
|
+
"NUMERIC",
|
|
117
|
+
"INDEXMISSING",
|
|
118
|
+
"numericIpMaskMin",
|
|
119
|
+
"NUMERIC",
|
|
120
|
+
"INDEXMISSING",
|
|
121
|
+
"numericIpMaskMax",
|
|
122
|
+
"NUMERIC",
|
|
123
|
+
"INDEXMISSING",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
let n = 0;
|
|
127
|
+
const write = async (fields: Record<string, string>): Promise<void> => {
|
|
128
|
+
await client.hSet(`${PREFIX}${n++}`, fields);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < IP_RULE_COUNT; i++) {
|
|
132
|
+
await write({
|
|
133
|
+
type: "block",
|
|
134
|
+
description: `bulk-ban-ip-${i}`,
|
|
135
|
+
numericIp: ipv4(i).toString(),
|
|
136
|
+
clientId: "global",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
for (let i = 0; i < CIDR_RULE_COUNT; i++) {
|
|
140
|
+
const { min, max } = ipv4CidrRange(i);
|
|
141
|
+
await write({
|
|
142
|
+
type: "block",
|
|
143
|
+
description: `bulk-ban-cidr-${i}`,
|
|
144
|
+
numericIpMaskMin: min.toString(),
|
|
145
|
+
numericIpMaskMax: max.toString(),
|
|
146
|
+
clientId: "global",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
for (let i = 0; i < MIXED_RULE_COUNT; i++) {
|
|
150
|
+
await write({
|
|
151
|
+
type: i % 2 === 0 ? "restrict" : "block",
|
|
152
|
+
description: `mixed-${i}`,
|
|
153
|
+
numericIp: ipv4(IP_RULE_COUNT + i).toString(),
|
|
154
|
+
clientId: `client-${i % 20}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Deferred cohort — the shape this change is about. Restrict, so the
|
|
158
|
+
// baseline clause would miss them entirely.
|
|
159
|
+
for (let i = 0; i < DEFERRED_RULE_COUNT; i++) {
|
|
160
|
+
await write({
|
|
161
|
+
type: "restrict",
|
|
162
|
+
description: `deferred-${i}`,
|
|
163
|
+
deferToVerify: "true",
|
|
164
|
+
numericIp: ipv4(IP_RULE_COUNT + MIXED_RULE_COUNT + i).toString(),
|
|
165
|
+
clientId: `client-${i % 20}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const total = n;
|
|
170
|
+
// Let RediSearch finish indexing.
|
|
171
|
+
for (;;) {
|
|
172
|
+
const info = (await client.sendCommand(["FT.INFO", INDEX])) as unknown[];
|
|
173
|
+
const idx = info.findIndex((v) => String(v) === "indexing");
|
|
174
|
+
if (idx === -1 || String(info[idx + 1]) === "0") break;
|
|
175
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(`population: ${total} rules`);
|
|
179
|
+
console.log(
|
|
180
|
+
` ${IP_RULE_COUNT} ip-block, ${CIDR_RULE_COUNT} cidr-block, ${MIXED_RULE_COUNT} mixed, ${DEFERRED_RULE_COUNT} deferred-restrict\n`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// The two probe shapes that dominate the split query: an exact-IP hit
|
|
184
|
+
// (the common ban case) and the CIDR mask-range probe (the expensive
|
|
185
|
+
// one — it walks two numeric ranges).
|
|
186
|
+
const hitIp = ipv4(1234).toString();
|
|
187
|
+
const cidrIp = ((192n << 24n) + (168n << 16n) + (7n << 8n) + 9n).toString();
|
|
188
|
+
const scope = "( @clientId:{client-3} | @clientId:{global} )";
|
|
189
|
+
|
|
190
|
+
const probes: Array<{ label: string; suffix: string }> = [
|
|
191
|
+
{ label: "ip:exact", suffix: `${scope} @numericIp:[${hitIp} ${hitIp}]` },
|
|
192
|
+
{
|
|
193
|
+
label: "ip:mask ",
|
|
194
|
+
suffix: `${scope} @numericIpMaskMin:[-inf ${cidrIp}] @numericIpMaskMax:[${cidrIp} +inf]`,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const probe of probes) {
|
|
199
|
+
console.log(`--- probe: ${probe.label} ---`);
|
|
200
|
+
for (const { name, clause } of CLAUSES) {
|
|
201
|
+
const query = `${clause} ${probe.suffix}`;
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < WARMUP; i++) {
|
|
204
|
+
await client.sendCommand([
|
|
205
|
+
"FT.SEARCH",
|
|
206
|
+
INDEX,
|
|
207
|
+
query,
|
|
208
|
+
"NOCONTENT",
|
|
209
|
+
"DIALECT",
|
|
210
|
+
"2",
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const samples: number[] = [];
|
|
215
|
+
let hits = 0;
|
|
216
|
+
for (let i = 0; i < ITERATIONS; i++) {
|
|
217
|
+
const t0 = process.hrtime.bigint();
|
|
218
|
+
const res = (await client.sendCommand([
|
|
219
|
+
"FT.SEARCH",
|
|
220
|
+
INDEX,
|
|
221
|
+
query,
|
|
222
|
+
"NOCONTENT",
|
|
223
|
+
"DIALECT",
|
|
224
|
+
"2",
|
|
225
|
+
])) as unknown[];
|
|
226
|
+
const t1 = process.hrtime.bigint();
|
|
227
|
+
samples.push(Number(t1 - t0) / 1e6);
|
|
228
|
+
hits = Number(res[0]);
|
|
229
|
+
}
|
|
230
|
+
samples.sort((a, b) => a - b);
|
|
231
|
+
const mean = samples.reduce((s, v) => s + v, 0) / samples.length;
|
|
232
|
+
console.log(
|
|
233
|
+
` ${name.padEnd(52)} hits=${String(hits).padStart(5)} ` +
|
|
234
|
+
`mean=${mean.toFixed(3)}ms p50=${percentile(samples, 50).toFixed(3)}ms ` +
|
|
235
|
+
`p95=${percentile(samples, 95).toFixed(3)}ms p99=${percentile(samples, 99).toFixed(3)}ms`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
console.log();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await client.sendCommand(["FT.DROPINDEX", INDEX]);
|
|
242
|
+
await client.flushAll();
|
|
243
|
+
await client.quit();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
main().catch((e: unknown) => {
|
|
247
|
+
console.error(e);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
});
|