@prosopo/user-access-policy 3.14.13 → 3.14.15
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 +2 -5
- package/.turbo/turbo-build$colon$tsc.log +14 -17
- package/.turbo/turbo-build.log +3 -9
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -0
- package/package.json +83 -81
- package/src/.export.ts +0 -65
- package/src/api/.export.ts +0 -25
- package/src/api/accessRulesApiClient.ts +0 -13
- package/src/api/delete/.export.ts +0 -18
- package/src/api/delete/deleteAllRules.ts +0 -47
- package/src/api/delete/deleteRuleGroups.ts +0 -96
- package/src/api/delete/deleteRules.ts +0 -87
- package/src/api/read/.export.ts +0 -25
- package/src/api/read/fetchRules.ts +0 -96
- package/src/api/read/findRuleIds.ts +0 -97
- package/src/api/read/getMissingIds.ts +0 -81
- package/src/api/ruleApiRoutes.ts +0 -146
- package/src/api/rulesApiClient.ts +0 -154
- package/src/api/write/.export.ts +0 -15
- package/src/api/write/insertRules.ts +0 -185
- package/src/api/write/rehashRules.ts +0 -85
- package/src/classifyBrowser.ts +0 -77
- package/src/classifyOs.ts +0 -74
- package/src/headerMatch.ts +0 -180
- package/src/matchedRule.ts +0 -99
- package/src/mongoose/.export.ts +0 -15
- package/src/mongoose/mongooseRuleSchema.ts +0 -75
- package/src/redis/.export.ts +0 -17
- package/src/redis/reader/redisAggregate.ts +0 -157
- package/src/redis/reader/redisRulesQuery.ts +0 -268
- package/src/redis/reader/redisRulesReader.ts +0 -738
- package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
- package/src/redis/redisClient.ts +0 -125
- package/src/redis/redisRuleIndex.ts +0 -121
- package/src/redis/redisRulesStorage.ts +0 -68
- package/src/redis/redisRulesWriter.ts +0 -172
- package/src/rule.ts +0 -122
- package/src/ruleInput/.export.ts +0 -19
- package/src/ruleInput/policyInput.ts +0 -120
- package/src/ruleInput/ruleInput.ts +0 -123
- package/src/ruleInput/userScopeInput.ts +0 -142
- package/src/ruleRecord.ts +0 -77
- package/src/rulesStorage.ts +0 -90
- package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
- package/src/tests/classifyBrowser.unit.test.ts +0 -138
- package/src/tests/classifyOs.unit.test.ts +0 -97
- package/src/tests/headerMatch.unit.test.ts +0 -350
- package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
- package/src/tests/matchedRule.unit.test.ts +0 -184
- package/src/tests/policyInput.unit.test.ts +0 -218
- package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
- package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
- package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
- package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
- package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
- package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
- package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
- package/src/tests/testLogger.ts +0 -38
- package/src/tests/transformRule.unit.test.ts +0 -345
- package/src/transformRule.ts +0 -133
- package/tsconfig.cjs.json +0 -41
- package/tsconfig.json +0 -47
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
|
@@ -1,234 +0,0 @@
|
|
|
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 { getBlockPoolClause } from "#policy/redis/reader/redisRulesQuery.js";
|
|
16
|
-
import { GLOBAL_CLIENT_SCOPE_SENTINEL, type UserScope } from "#policy/rule.js";
|
|
17
|
-
|
|
18
|
-
// Escapes special characters in Redis TAG queries. Mirrors the escape
|
|
19
|
-
// function in redisRulesQuery.ts — kept local to avoid coupling the two
|
|
20
|
-
// query builders since they'll diverge in shape.
|
|
21
|
-
const escapeTagValue = (value: string): string =>
|
|
22
|
-
value.replace(/([,.<>{}\[\]"':;!@#$%^&*()\-+=~|/\\])/g, "\\$1");
|
|
23
|
-
|
|
24
|
-
// Fields where TAG queries need escaping (they may hold JSON etc.).
|
|
25
|
-
const FIELDS_REQUIRING_ESCAPE: ReadonlySet<keyof UserScope> = new Set([
|
|
26
|
-
"coords",
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
// Fields indexed as NUMERIC — require range syntax `@x:[N N]` not TAG.
|
|
30
|
-
const NUMERIC_FIELDS: ReadonlySet<keyof UserScope> = new Set(["asn"]);
|
|
31
|
-
|
|
32
|
-
// Enumerating the scalar (non-IP) user-scope fields keeps the "no
|
|
33
|
-
// user-scope constraint" fall-through query stable when userScopeSchema
|
|
34
|
-
// grows a new field — the enumeration below drives ismissing() clauses.
|
|
35
|
-
const SCALAR_USER_SCOPE_FIELDS: ReadonlyArray<keyof UserScope> = [
|
|
36
|
-
"userId",
|
|
37
|
-
"ja4Hash",
|
|
38
|
-
"headersHash",
|
|
39
|
-
"userAgentHash",
|
|
40
|
-
"headHash",
|
|
41
|
-
"coords",
|
|
42
|
-
"countryCode",
|
|
43
|
-
"asn",
|
|
44
|
-
"os",
|
|
45
|
-
"browser",
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
const ALL_USER_SCOPE_FIELDS: ReadonlyArray<keyof UserScope> = [
|
|
49
|
-
...SCALAR_USER_SCOPE_FIELDS,
|
|
50
|
-
"numericIp",
|
|
51
|
-
"numericIpMaskMin",
|
|
52
|
-
"numericIpMaskMax",
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
// Global rules written under the new sentinel are found via
|
|
56
|
-
// `@clientId:{global}`; the ismissing() fallback covers legacy rules
|
|
57
|
-
// that predate the sentinel stamp (rehash-all migrates them). This
|
|
58
|
-
// disjunction still hits the clientId posting list rather than walking
|
|
59
|
-
// the full rule set the way the old single-query path did.
|
|
60
|
-
const GLOBAL_SCOPE_INNER = `@clientId:{${GLOBAL_CLIENT_SCOPE_SENTINEL}} | ismissing(@clientId)`;
|
|
61
|
-
|
|
62
|
-
const buildScopeClause = (clientId: string | undefined): string => {
|
|
63
|
-
if (clientId === undefined) {
|
|
64
|
-
return `( ${GLOBAL_SCOPE_INNER} )`;
|
|
65
|
-
}
|
|
66
|
-
return `( @clientId:{${clientId}} | ${GLOBAL_SCOPE_INNER} )`;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const buildFieldClause = (
|
|
70
|
-
field: keyof UserScope,
|
|
71
|
-
value: unknown,
|
|
72
|
-
): string | null => {
|
|
73
|
-
if (value === undefined) {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
const stringValue = String(value);
|
|
77
|
-
if (NUMERIC_FIELDS.has(field)) {
|
|
78
|
-
return `@${field}:[${stringValue} ${stringValue}]`;
|
|
79
|
-
}
|
|
80
|
-
const queryValue = FIELDS_REQUIRING_ESCAPE.has(field)
|
|
81
|
-
? escapeTagValue(stringValue)
|
|
82
|
-
: stringValue;
|
|
83
|
-
return `@${field}:{${queryValue}}`;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
type SubQuery = {
|
|
87
|
-
// A short label used for logging + metrics. Not part of the query
|
|
88
|
-
// wire format.
|
|
89
|
-
kind: string;
|
|
90
|
-
query: string;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Build the set of FT sub-queries that together cover every rule that
|
|
95
|
-
* could apply to a request.
|
|
96
|
-
*
|
|
97
|
-
* The single-query design this replaces produced one FT.AGGREGATE whose
|
|
98
|
-
* candidate set was the entire scope's rule pool — the APPLY / SORTBY
|
|
99
|
-
* pipeline then walked every rule per request, which degrades sharply
|
|
100
|
-
* once rule populations reach the 10k+ range. The split approach fires
|
|
101
|
-
* one probe per populated request field. Each probe uses the field's
|
|
102
|
-
* own index posting list, so a 17k IP-ban population collapses to 1
|
|
103
|
-
* result (exact IP) plus a handful (CIDR ranges containing the IP)
|
|
104
|
-
* instead of forcing a full-set scan.
|
|
105
|
-
*
|
|
106
|
-
* A single fall-through probe covers rules with no user-scope constraint
|
|
107
|
-
* (client-wide restriction); those are rare so the ismissing()-heavy
|
|
108
|
-
* query is cheap in practice.
|
|
109
|
-
*
|
|
110
|
-
* Rank + strict-match filtering happens in JS via `rankCandidateRules`
|
|
111
|
-
* after the union — the FT layer is used purely as a candidate fetcher.
|
|
112
|
-
*
|
|
113
|
-
* `blockOnly` narrows the candidate set to rules with `type:{block}` —
|
|
114
|
-
* used by the hard-block middleware which never needs Restrict rules.
|
|
115
|
-
* `includeDeferred` widens that pool back out to
|
|
116
|
-
* `(@type:{block} | @deferToVerify:{true})` for the verify-time
|
|
117
|
-
* hard-block lookup, where a deferred Restrict is a valid block.
|
|
118
|
-
* Every other caller passes false so both Block and Restrict rules are
|
|
119
|
-
* fetched; the JS-side ranker picks the right one via specificity +
|
|
120
|
-
* severity. Merging the block and restrict paths behind one split-query
|
|
121
|
-
* fixed the Twickets IP-rule regression: the previous FT.AGGREGATE ranker
|
|
122
|
-
* used for non-block lookups was truncating specific-IP restrict rules
|
|
123
|
-
* out of the top-N when many higher-specificity irrelevant rules matched
|
|
124
|
-
* the greedy `ismissing()` disjunction.
|
|
125
|
-
*/
|
|
126
|
-
export const buildScopedRulesSubQueries = (
|
|
127
|
-
userScope: UserScope,
|
|
128
|
-
clientId: string | undefined,
|
|
129
|
-
options: { blockOnly?: boolean; includeDeferred?: boolean } = {},
|
|
130
|
-
): SubQuery[] => {
|
|
131
|
-
const scopeClause = buildScopeClause(clientId);
|
|
132
|
-
|
|
133
|
-
// `includeDeferred` emits the probe set twice — once for plain hard
|
|
134
|
-
// blocks, once for deferred rules — rather than widening one type
|
|
135
|
-
// clause to `(@type:{block} | @deferToVerify:{true})`.
|
|
136
|
-
//
|
|
137
|
-
// Widening merges both populations into a single probe, so they share
|
|
138
|
-
// one SPLIT_MAX_CANDIDATES_PER_SUB budget and a dense deferred cohort
|
|
139
|
-
// can truncate hard blocks out of the candidate set. Measured on a
|
|
140
|
-
// scope with 400 block + 400 deferred rules on one ja4Hash: the merged
|
|
141
|
-
// probe returns 800 against a cap of 500, so 300 candidates are
|
|
142
|
-
// dropped with no ordering guarantee about which. That is precisely
|
|
143
|
-
// the crowding `blockOnly` was introduced to prevent.
|
|
144
|
-
//
|
|
145
|
-
// Two disjoint probe sets each get their own budget, so a hard block
|
|
146
|
-
// is never displaced by a deferred rule. The pools are disjoint by
|
|
147
|
-
// construction (the block probe excludes deferred), so the union
|
|
148
|
-
// double-counts nothing.
|
|
149
|
-
// Probe `kind` is only namespaced when there are genuinely two pools;
|
|
150
|
-
// single-pool callers keep the original unprefixed labels.
|
|
151
|
-
const typePools: Array<{ tag: string; clause: string }> = options.blockOnly
|
|
152
|
-
? options.includeDeferred === true
|
|
153
|
-
? [
|
|
154
|
-
{ tag: "block", clause: `${getBlockPoolClause(false)} ` },
|
|
155
|
-
{ tag: "deferred", clause: "@deferToVerify:{true} " },
|
|
156
|
-
]
|
|
157
|
-
: [{ tag: "", clause: `${getBlockPoolClause(false)} ` }]
|
|
158
|
-
: [{ tag: "", clause: "" }];
|
|
159
|
-
|
|
160
|
-
const subQueries: SubQuery[] = [];
|
|
161
|
-
for (const pool of typePools) {
|
|
162
|
-
const prefix = `${pool.clause}${scopeClause}`;
|
|
163
|
-
const label = pool.tag === "" ? "" : `${pool.tag}:`;
|
|
164
|
-
subQueries.push(...buildProbes(userScope, prefix, label));
|
|
165
|
-
}
|
|
166
|
-
return subQueries;
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const buildProbes = (
|
|
170
|
-
userScope: UserScope,
|
|
171
|
-
prefix: string,
|
|
172
|
-
label: string,
|
|
173
|
-
): SubQuery[] => {
|
|
174
|
-
const subQueries: SubQuery[] = [];
|
|
175
|
-
|
|
176
|
-
// One probe per populated scalar user-scope field. Each uses that
|
|
177
|
-
// field's posting list rather than intersecting ismissing() across
|
|
178
|
-
// the full set.
|
|
179
|
-
for (const field of SCALAR_USER_SCOPE_FIELDS) {
|
|
180
|
-
const clause = buildFieldClause(field, userScope[field]);
|
|
181
|
-
if (clause === null) {
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
subQueries.push({
|
|
185
|
-
kind: `${label}field:${field}`,
|
|
186
|
-
query: `${prefix} ${clause}`,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// IP: two disjoint probes so each hits a single index. The exact-IP
|
|
191
|
-
// probe catches individual-IP bans (the dominant shape in bulk-ban
|
|
192
|
-
// scripts). The mask-range probe catches CIDR aggregates containing
|
|
193
|
-
// the request IP. Splitting them means neither has to walk a mixed
|
|
194
|
-
// posting list — the mask-range probe was the slower of the two
|
|
195
|
-
// under load, so isolating it lets Redis skip it entirely for hits
|
|
196
|
-
// on the fast path.
|
|
197
|
-
const requestIp = userScope.numericIp;
|
|
198
|
-
if (requestIp !== undefined) {
|
|
199
|
-
subQueries.push({
|
|
200
|
-
kind: `${label}ip:exact`,
|
|
201
|
-
query: `${prefix} @numericIp:[${requestIp} ${requestIp}]`,
|
|
202
|
-
});
|
|
203
|
-
subQueries.push({
|
|
204
|
-
kind: `${label}ip:mask`,
|
|
205
|
-
query: `${prefix} @numericIpMaskMin:[-inf ${requestIp}] @numericIpMaskMax:[${requestIp} +inf]`,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Fall-through: rules that constrain nothing on the user scope
|
|
210
|
-
// ("block everything for this client / everyone"). Rare in
|
|
211
|
-
// practice — a handful per tenant at most — so the ismissing()
|
|
212
|
-
// intersection is cheap despite touching every field.
|
|
213
|
-
const noScopeIsmissing = ALL_USER_SCOPE_FIELDS.map(
|
|
214
|
-
(field) => `ismissing(@${field})`,
|
|
215
|
-
).join(" ");
|
|
216
|
-
subQueries.push({
|
|
217
|
-
kind: `${label}no-user-scope`,
|
|
218
|
-
query: `${prefix} ${noScopeIsmissing}`,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
return subQueries;
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Backwards-compatible alias for the previous block-only builder. New
|
|
226
|
-
* callers should use `buildScopedRulesSubQueries` directly.
|
|
227
|
-
*
|
|
228
|
-
* @deprecated use `buildScopedRulesSubQueries(..., { blockOnly: true })`
|
|
229
|
-
*/
|
|
230
|
-
export const buildScopedBlockSubQueries = (
|
|
231
|
-
userScope: UserScope,
|
|
232
|
-
clientId: string | undefined,
|
|
233
|
-
): SubQuery[] =>
|
|
234
|
-
buildScopedRulesSubQueries(userScope, clientId, { blockOnly: true });
|
package/src/redis/redisClient.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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 type { Logger } from "@prosopo/logger";
|
|
16
|
-
import type { RedisClientType } from "redis";
|
|
17
|
-
import { type ZodType, type ZodTypeDef, z } from "zod";
|
|
18
|
-
|
|
19
|
-
export const REDIS_BATCH_SIZE = 1_000;
|
|
20
|
-
|
|
21
|
-
export const getMissingRedisKeys = async (
|
|
22
|
-
client: RedisClientType,
|
|
23
|
-
keys: string[],
|
|
24
|
-
): Promise<string[]> => {
|
|
25
|
-
const queries = client.multi();
|
|
26
|
-
|
|
27
|
-
keys.map((key) => {
|
|
28
|
-
queries.exists(key);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const records: unknown[] = await queries.exec();
|
|
32
|
-
|
|
33
|
-
const missingKeys: string[] = [];
|
|
34
|
-
|
|
35
|
-
records.map((exists, recordIndex) => {
|
|
36
|
-
if ("0" === String(exists)) {
|
|
37
|
-
const key = keys[recordIndex];
|
|
38
|
-
|
|
39
|
-
if (key) {
|
|
40
|
-
missingKeys.push(key);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
return missingKeys;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export const fetchRedisHashRecords = async (
|
|
49
|
-
client: RedisClientType,
|
|
50
|
-
keys: string[],
|
|
51
|
-
logger: Logger,
|
|
52
|
-
): Promise<{ records: object[]; expirations: (number | undefined)[] }> => {
|
|
53
|
-
const rulesPipe = client.multi();
|
|
54
|
-
const expirationPipe = client.multi();
|
|
55
|
-
|
|
56
|
-
for (const key of keys) {
|
|
57
|
-
rulesPipe.hGetAll(key);
|
|
58
|
-
expirationPipe.expireTime(key);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const records = (await rulesPipe.exec()) as object[];
|
|
62
|
-
const expirationRecords = (await expirationPipe.exec()) as unknown[];
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
records: records,
|
|
66
|
-
expirations: parseExpirationRecords(expirationRecords, logger),
|
|
67
|
-
};
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// `recordSchema` is intentionally typed with `unknown` as the input
|
|
71
|
-
// position. Some schemas use `z.preprocess` (e.g. accessPolicyInput's
|
|
72
|
-
// deferToVerify boolean coercion from Redis strings), which widens the
|
|
73
|
-
// zod input type. The strict `ZodType<T, ZodTypeDef, T>` form rejects
|
|
74
|
-
// those at the call site even though they parse correctly at runtime.
|
|
75
|
-
export const parseRedisRecords = <T>(
|
|
76
|
-
records: unknown[],
|
|
77
|
-
recordSchema: ZodType<T, ZodTypeDef, unknown>,
|
|
78
|
-
logger: Logger,
|
|
79
|
-
): T[] =>
|
|
80
|
-
records.flatMap((record) => {
|
|
81
|
-
const parseResult = recordSchema.safeParse(record);
|
|
82
|
-
|
|
83
|
-
if (parseResult.success) {
|
|
84
|
-
return [parseResult.data];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
logger.error(() => ({
|
|
88
|
-
msg: "Failed to parse Redis record",
|
|
89
|
-
data: { record, error: parseResult.error },
|
|
90
|
-
}));
|
|
91
|
-
|
|
92
|
-
return [];
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
const expirationRecordSchema = z.coerce.number();
|
|
96
|
-
// Redis returns -1 when expiration is not set
|
|
97
|
-
const UNSET_EXPIRATION_VALUE = -1;
|
|
98
|
-
|
|
99
|
-
const parseExpirationRecords = <T>(
|
|
100
|
-
records: unknown[],
|
|
101
|
-
logger: Logger,
|
|
102
|
-
): (number | undefined)[] =>
|
|
103
|
-
records.flatMap((record) => {
|
|
104
|
-
const parseResult = expirationRecordSchema.safeParse(record);
|
|
105
|
-
|
|
106
|
-
if (parseResult.success) {
|
|
107
|
-
const expiration =
|
|
108
|
-
UNSET_EXPIRATION_VALUE === parseResult.data
|
|
109
|
-
? undefined
|
|
110
|
-
: parseResult.data;
|
|
111
|
-
|
|
112
|
-
return [expiration];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
logger.error(() => ({
|
|
116
|
-
msg: "Failed to parse Redis expiration record",
|
|
117
|
-
data: {
|
|
118
|
-
record,
|
|
119
|
-
error: parseResult.error,
|
|
120
|
-
},
|
|
121
|
-
}));
|
|
122
|
-
|
|
123
|
-
// ensure consistent output length
|
|
124
|
-
return [undefined];
|
|
125
|
-
});
|
|
@@ -1,121 +0,0 @@
|
|
|
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 type { AllKeys, Keys } from "@prosopo/common";
|
|
16
|
-
import type { RedisIndex } from "@prosopo/redis-client";
|
|
17
|
-
import { type RediSearchSchema, SCHEMA_FIELD_TYPE } from "@redis/search";
|
|
18
|
-
import type {
|
|
19
|
-
AccessRule,
|
|
20
|
-
PolicyScope,
|
|
21
|
-
UserAttributes,
|
|
22
|
-
UserIp,
|
|
23
|
-
UserScope,
|
|
24
|
-
} from "#policy/rule.js";
|
|
25
|
-
import { makeAccessRuleHash } from "#policy/transformRule.js";
|
|
26
|
-
|
|
27
|
-
export const userIpRedisSchema: RediSearchSchema = {
|
|
28
|
-
numericIpMaskMin: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
29
|
-
numericIpMaskMax: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
30
|
-
numericIp: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
31
|
-
} satisfies AllKeys<UserIp>;
|
|
32
|
-
|
|
33
|
-
export const userAttributesRedisSchema: RediSearchSchema = {
|
|
34
|
-
userId: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
35
|
-
ja4Hash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
36
|
-
headersHash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
37
|
-
userAgentHash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
38
|
-
headHash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
39
|
-
// Use pipe separator for coords since JSON strings contain commas
|
|
40
|
-
coords: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true, SEPARATOR: "|" },
|
|
41
|
-
countryCode: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
42
|
-
asn: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
43
|
-
os: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
44
|
-
browser: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
45
|
-
// Header-restriction fields. NONE of these are queried on the hot path:
|
|
46
|
-
// the split-query builder probes neither `headerMatch` nor the
|
|
47
|
-
// name/value/operator triple, and a header rule populates none of the
|
|
48
|
-
// fields that builder does probe, so it is returned by the `no-user-scope`
|
|
49
|
-
// fall-through probe (the same route `os` rules take) and the concrete
|
|
50
|
-
// condition is evaluated in code by `accessRuleHeaderMatches`. They are
|
|
51
|
-
// indexed to satisfy the schema exhaustiveness check, and `headerMatch`
|
|
52
|
-
// additionally gates candidacy JS-side in `ruleApplies` and scores one
|
|
53
|
-
// specificity point in the reader's SPECIFICITY_EXPR.
|
|
54
|
-
headerMatch: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
55
|
-
headerName: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
56
|
-
headerValue: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
57
|
-
headerOperator: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
58
|
-
webBotAuthAgent: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
59
|
-
} satisfies AllKeys<UserAttributes>;
|
|
60
|
-
|
|
61
|
-
export const userScopeRedisSchema: RediSearchSchema = {
|
|
62
|
-
...userAttributesRedisSchema,
|
|
63
|
-
...userIpRedisSchema,
|
|
64
|
-
} satisfies Keys<UserScope>;
|
|
65
|
-
|
|
66
|
-
export const policyScopeRedisSchema: RediSearchSchema = {
|
|
67
|
-
clientId: {
|
|
68
|
-
type: SCHEMA_FIELD_TYPE.TAG,
|
|
69
|
-
INDEXMISSING: true,
|
|
70
|
-
},
|
|
71
|
-
} satisfies AllKeys<PolicyScope>;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Note on the field type decision
|
|
75
|
-
*
|
|
76
|
-
* TAG is designed for the exact value matching
|
|
77
|
-
* TEXT is designed for the word-based and pattern matching
|
|
78
|
-
*
|
|
79
|
-
* For our goal TAG fits perfectly and, more performant
|
|
80
|
-
*/
|
|
81
|
-
export const accessRuleRedisSchema: RediSearchSchema = {
|
|
82
|
-
...policyScopeRedisSchema,
|
|
83
|
-
...userScopeRedisSchema,
|
|
84
|
-
groupId: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
85
|
-
// Indexed so request-time callers that only care about Block policies
|
|
86
|
-
// (checkForHardBlock, blockMiddleware) can pre-filter via @type:{block}
|
|
87
|
-
// before the SERVER_SIDE_RANK_TOP_N cap kicks in. Without this, dense
|
|
88
|
-
// Restrict / routing-Block populations push the hard-block rules out
|
|
89
|
-
// of the top-N candidate set and the lookup silently misses them.
|
|
90
|
-
type: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
91
|
-
// Indexed so `checkForHardBlock` can widen its Block-only pool to
|
|
92
|
-
// `(@type:{block} | @deferToVerify:{true})`. Deferred rules are
|
|
93
|
-
// skipped at request time and enforced at verify, so a deferred
|
|
94
|
-
// Restrict is a legitimate hard block — without this field in the
|
|
95
|
-
// index it can't be selected server-side and never fires.
|
|
96
|
-
// Stored by `getRedisRuleValue` as the string "true"/"false".
|
|
97
|
-
deferToVerify: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
98
|
-
} satisfies Keys<AccessRule>;
|
|
99
|
-
|
|
100
|
-
export const ACCESS_RULES_REDIS_INDEX_NAME = "index:user-access-rules";
|
|
101
|
-
|
|
102
|
-
// names take space, so we use an acronym instead of the long-tailed one
|
|
103
|
-
export const ACCESS_RULE_REDIS_KEY_PREFIX = "uar:";
|
|
104
|
-
|
|
105
|
-
// Re-exported from #policy/rule.js — that module is types-only so both
|
|
106
|
-
// the parse side (policyInput.ts) and the write/index side (this file
|
|
107
|
-
// and redisRulesWriter.ts) can import the sentinel without pulling
|
|
108
|
-
// transformRule into a cycle.
|
|
109
|
-
export { GLOBAL_CLIENT_SCOPE_SENTINEL } from "#policy/rule.js";
|
|
110
|
-
|
|
111
|
-
export const accessRulesRedisIndex: RedisIndex = {
|
|
112
|
-
name: ACCESS_RULES_REDIS_INDEX_NAME,
|
|
113
|
-
schema: accessRuleRedisSchema,
|
|
114
|
-
options: {
|
|
115
|
-
ON: "HASH" as const,
|
|
116
|
-
PREFIX: [ACCESS_RULE_REDIS_KEY_PREFIX],
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
export const getAccessRuleRedisKey = (rule: AccessRule): string =>
|
|
121
|
-
ACCESS_RULE_REDIS_KEY_PREFIX + makeAccessRuleHash(rule);
|
|
@@ -1,68 +0,0 @@
|
|
|
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 type { Logger } from "@prosopo/logger";
|
|
16
|
-
import type { RedisConnection } from "@prosopo/redis-client";
|
|
17
|
-
import type {
|
|
18
|
-
AccessRulesReader,
|
|
19
|
-
AccessRulesStorage,
|
|
20
|
-
AccessRulesWriter,
|
|
21
|
-
} from "#policy/rulesStorage.js";
|
|
22
|
-
import {
|
|
23
|
-
DummyRedisRulesReader,
|
|
24
|
-
RedisRulesReader,
|
|
25
|
-
} from "./reader/redisRulesReader.js";
|
|
26
|
-
import { DummyRedisRulesWriter, RedisRulesWriter } from "./redisRulesWriter.js";
|
|
27
|
-
|
|
28
|
-
export const createRedisAccessRulesStorage = (
|
|
29
|
-
connection: RedisConnection,
|
|
30
|
-
logger: Logger,
|
|
31
|
-
): AccessRulesStorage => {
|
|
32
|
-
const storage: AccessRulesStorage = composeStorage(
|
|
33
|
-
new DummyRedisRulesReader(logger),
|
|
34
|
-
new DummyRedisRulesWriter(logger),
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
connection.getClient().then((client) => {
|
|
38
|
-
const realStorage = composeStorage(
|
|
39
|
-
new RedisRulesReader(client, logger),
|
|
40
|
-
new RedisRulesWriter(client, logger),
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
// use assigning instead of var overwriting to keep the object reference
|
|
44
|
-
Object.assign(storage, realStorage);
|
|
45
|
-
|
|
46
|
-
logger.info(() => ({
|
|
47
|
-
msg: "RedisAccessRules storage got a ready Redis client",
|
|
48
|
-
}));
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
return storage;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const composeStorage = (
|
|
55
|
-
reader: AccessRulesReader,
|
|
56
|
-
writer: AccessRulesWriter,
|
|
57
|
-
): AccessRulesStorage => ({
|
|
58
|
-
// reader
|
|
59
|
-
fetchRules: reader.fetchRules.bind(reader),
|
|
60
|
-
getMissingRuleIds: reader.getMissingRuleIds.bind(reader),
|
|
61
|
-
findRules: reader.findRules.bind(reader),
|
|
62
|
-
findRuleIds: reader.findRuleIds.bind(reader),
|
|
63
|
-
fetchAllRuleIds: reader.fetchAllRuleIds.bind(reader),
|
|
64
|
-
// writer
|
|
65
|
-
insertRules: writer.insertRules.bind(writer),
|
|
66
|
-
deleteRules: writer.deleteRules.bind(writer),
|
|
67
|
-
deleteAllRules: writer.deleteAllRules.bind(writer),
|
|
68
|
-
});
|