@prosopo/user-access-policy 3.14.14 → 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 +18 -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
package/src/headerMatch.ts
DELETED
|
@@ -1,180 +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
|
-
/**
|
|
16
|
-
* Operators for an access rule's arbitrary-header condition.
|
|
17
|
-
*
|
|
18
|
-
* `equals` / `contains` are the deny-list operators (block when the request's
|
|
19
|
-
* header matches). `notEquals` / `notContains` are their allow-list negations
|
|
20
|
-
* (block when the request's header does NOT match — including when the header
|
|
21
|
-
* is absent), so an allow-list card that says "only let requests with header X
|
|
22
|
-
* through" desugars to `notEquals` / `notContains` Block rules, exactly the way
|
|
23
|
-
* the OS allow-list desugars to Block rules on the complement.
|
|
24
|
-
*
|
|
25
|
-
* `notEqualsAny` / `notContainsAny` are the multi-value form of those two: the
|
|
26
|
-
* rule's `headerValue` holds a list (see `encodeHeaderValueList`) and the rule
|
|
27
|
-
* blocks unless the request's header matches *one of* the listed values. They
|
|
28
|
-
* exist because an allow-list over several values of the SAME header cannot be
|
|
29
|
-
* expressed as several single-value rules — each rule fires independently, so
|
|
30
|
-
* `notEquals ios` + `notEquals android` blocks both an iOS and an Android
|
|
31
|
-
* request. One rule holding both values is the only way to get the "any of"
|
|
32
|
-
* semantics the allow-list needs.
|
|
33
|
-
*/
|
|
34
|
-
export const HEADER_OPERATORS = [
|
|
35
|
-
"equals",
|
|
36
|
-
"contains",
|
|
37
|
-
"notEquals",
|
|
38
|
-
"notContains",
|
|
39
|
-
"notEqualsAny",
|
|
40
|
-
"notContainsAny",
|
|
41
|
-
] as const;
|
|
42
|
-
|
|
43
|
-
export type HeaderOperator = (typeof HEADER_OPERATORS)[number];
|
|
44
|
-
|
|
45
|
-
export const isHeaderOperator = (
|
|
46
|
-
value: string | undefined,
|
|
47
|
-
): value is HeaderOperator =>
|
|
48
|
-
value !== undefined &&
|
|
49
|
-
(HEADER_OPERATORS as ReadonlyArray<string>).includes(value);
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Sentinel value carried both on every request's user scope (see
|
|
53
|
-
* `getRequestUserScope`) and on every header-restriction rule. Header rules
|
|
54
|
-
* match on data (`headerName` / `headerValue` / `headerOperator`) that Redis
|
|
55
|
-
* cannot evaluate — substring `contains` and per-rule operators aren't
|
|
56
|
-
* expressible as a TAG query, and an allow-list rule must still fire on a
|
|
57
|
-
* request that OMITS the target header, so candidate selection can't key off
|
|
58
|
-
* header presence. Redis returns header rules via the reader's
|
|
59
|
-
* `no-user-scope` fall-through probe (they constrain none of the indexed
|
|
60
|
-
* scope dimensions); this marker is what keeps them candidates through the
|
|
61
|
-
* JS-side `ruleApplies` scalar check, which would otherwise drop a rule
|
|
62
|
-
* carrying a scope field the request lacks. The concrete condition is then
|
|
63
|
-
* checked by `accessRuleHeaderMatches`.
|
|
64
|
-
*/
|
|
65
|
-
export const HEADER_RULE_MARKER = "1";
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Encode the value list carried by a multi-value operator (`notEqualsAny` /
|
|
69
|
-
* `notContainsAny`) into the single `headerValue` string an access rule can
|
|
70
|
-
* store. JSON rather than a delimiter because header values are arbitrary
|
|
71
|
-
* text — any separator we picked could legitimately occur inside a value.
|
|
72
|
-
*/
|
|
73
|
-
export const encodeHeaderValueList = (values: ReadonlyArray<string>): string =>
|
|
74
|
-
JSON.stringify(values);
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Inverse of {@link encodeHeaderValueList}, tolerant of rules written by hand
|
|
78
|
-
* (e.g. via the access-policy CLI) that carry a single literal value instead of
|
|
79
|
-
* an encoded list.
|
|
80
|
-
*
|
|
81
|
-
* Returns `undefined` for a value that looks list-encoded but isn't usable — an
|
|
82
|
-
* empty list, a non-array, non-string members, or malformed JSON. Callers treat
|
|
83
|
-
* that as a malformed rule and decline to fire it, so a garbled list can't
|
|
84
|
-
* silently block all traffic.
|
|
85
|
-
*/
|
|
86
|
-
export const decodeHeaderValueList = (value: string): string[] | undefined => {
|
|
87
|
-
if (!value.trimStart().startsWith("[")) {
|
|
88
|
-
// Not list-encoded: treat the whole string as a one-value list.
|
|
89
|
-
return [value];
|
|
90
|
-
}
|
|
91
|
-
let parsed: unknown;
|
|
92
|
-
try {
|
|
93
|
-
parsed = JSON.parse(value);
|
|
94
|
-
} catch {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
if (
|
|
98
|
-
!Array.isArray(parsed) ||
|
|
99
|
-
parsed.length === 0 ||
|
|
100
|
-
!parsed.every((entry): entry is string => typeof entry === "string")
|
|
101
|
-
) {
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
return parsed;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Evaluate a single header condition against a request's headers. Returns
|
|
109
|
-
* `true` when the condition is satisfied — i.e. when a Block rule carrying it
|
|
110
|
-
* should fire for this request.
|
|
111
|
-
*
|
|
112
|
-
* `headers` is expected to be keyed by lower-cased header name (HTTP header
|
|
113
|
-
* names are case-insensitive); `headerName` is lower-cased here defensively.
|
|
114
|
-
* The negated operators treat an absent header as "not matching the value",
|
|
115
|
-
* which is what makes an allow-list block a request that drops the header.
|
|
116
|
-
*/
|
|
117
|
-
export const evaluateHeaderCondition = (
|
|
118
|
-
headerName: string,
|
|
119
|
-
operator: HeaderOperator,
|
|
120
|
-
value: string,
|
|
121
|
-
headers: Record<string, string>,
|
|
122
|
-
): boolean => {
|
|
123
|
-
const actual = headers[headerName.toLowerCase()];
|
|
124
|
-
switch (operator) {
|
|
125
|
-
case "equals":
|
|
126
|
-
return actual !== undefined && actual === value;
|
|
127
|
-
case "contains":
|
|
128
|
-
return actual?.includes(value) ?? false;
|
|
129
|
-
case "notEquals":
|
|
130
|
-
return actual === undefined || actual !== value;
|
|
131
|
-
case "notContains":
|
|
132
|
-
return actual === undefined || !actual.includes(value);
|
|
133
|
-
case "notEqualsAny": {
|
|
134
|
-
const values = decodeHeaderValueList(value);
|
|
135
|
-
if (values === undefined) {
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
return actual === undefined || !values.includes(actual);
|
|
139
|
-
}
|
|
140
|
-
case "notContainsAny": {
|
|
141
|
-
const values = decodeHeaderValueList(value);
|
|
142
|
-
if (values === undefined) {
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
return (
|
|
146
|
-
actual === undefined ||
|
|
147
|
-
!values.some((candidate) => actual.includes(candidate))
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Whether a rule's header condition (if any) is satisfied by the request's
|
|
155
|
-
* headers. A rule with no `headerName` carries no header constraint, so it is
|
|
156
|
-
* treated as satisfied and the rule's other dimensions decide the match. A
|
|
157
|
-
* rule with a `headerName` but an unrecognised operator is malformed and never
|
|
158
|
-
* matches (fail-safe: a garbled rule must not silently block traffic).
|
|
159
|
-
*/
|
|
160
|
-
export const accessRuleHeaderMatches = (
|
|
161
|
-
rule: {
|
|
162
|
-
headerName?: string;
|
|
163
|
-
headerValue?: string;
|
|
164
|
-
headerOperator?: string;
|
|
165
|
-
},
|
|
166
|
-
headers: Record<string, string>,
|
|
167
|
-
): boolean => {
|
|
168
|
-
if (rule.headerName === undefined) {
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
if (!isHeaderOperator(rule.headerOperator)) {
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
return evaluateHeaderCondition(
|
|
175
|
-
rule.headerName,
|
|
176
|
-
rule.headerOperator,
|
|
177
|
-
rule.headerValue ?? "",
|
|
178
|
-
headers,
|
|
179
|
-
);
|
|
180
|
-
};
|
package/src/matchedRule.ts
DELETED
|
@@ -1,99 +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 { MatchedAccessRule, MatchedRuleCondition } from "@prosopo/types";
|
|
16
|
-
import type { AccessRule } from "#policy/rule.js";
|
|
17
|
-
import {
|
|
18
|
-
type UserScopeRecordField,
|
|
19
|
-
getUserScopeRecordFromAccessRuleRecord,
|
|
20
|
-
userScopeRecordFields,
|
|
21
|
-
} from "#policy/ruleRecord.js";
|
|
22
|
-
import {
|
|
23
|
-
makeAccessRuleHash,
|
|
24
|
-
transformAccessRuleIntoRecord,
|
|
25
|
-
} from "#policy/transformRule.js";
|
|
26
|
-
|
|
27
|
-
// A `coords` polygon serialises to an unbounded JSON string. The audit UI only
|
|
28
|
-
// needs enough to recognise the rule, and the snapshot is copied onto every
|
|
29
|
-
// record the rule acts on, so cap it rather than dragging kilobytes through
|
|
30
|
-
// Mongo on each blocked request.
|
|
31
|
-
const MAX_CONDITION_VALUE_LENGTH = 256;
|
|
32
|
-
|
|
33
|
-
const toCondition = (
|
|
34
|
-
field: UserScopeRecordField,
|
|
35
|
-
value: unknown,
|
|
36
|
-
): MatchedRuleCondition | undefined => {
|
|
37
|
-
if (value === undefined || value === null) return undefined;
|
|
38
|
-
// `asn` is a number on the rule; every other scope field is a string.
|
|
39
|
-
const asString = typeof value === "string" ? value : String(value);
|
|
40
|
-
if (asString.length === 0) return undefined;
|
|
41
|
-
return asString.length > MAX_CONDITION_VALUE_LENGTH
|
|
42
|
-
? {
|
|
43
|
-
field,
|
|
44
|
-
value: `${asString.slice(0, MAX_CONDITION_VALUE_LENGTH)}…`,
|
|
45
|
-
truncated: true,
|
|
46
|
-
}
|
|
47
|
-
: { field, value: asString };
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Derive the rule's populated scope fields in record form.
|
|
52
|
-
*
|
|
53
|
-
* Goes through `transformAccessRuleIntoRecord` so the IP triple collapses back
|
|
54
|
-
* to the human-readable `ip` / `ipMask` the operator originally typed, rather
|
|
55
|
-
* than the numeric range the matcher works in, and so the field names match
|
|
56
|
-
* the vocabulary the portal's Access Control page already labels conditions
|
|
57
|
-
* with (`ip`, `ipMask`, `userAgent`, `ja4Hash`, …).
|
|
58
|
-
*/
|
|
59
|
-
const deriveConditions = (rule: AccessRule): MatchedRuleCondition[] => {
|
|
60
|
-
let scope: Partial<Record<UserScopeRecordField, unknown>>;
|
|
61
|
-
try {
|
|
62
|
-
scope = getUserScopeRecordFromAccessRuleRecord(
|
|
63
|
-
transformAccessRuleIntoRecord(rule),
|
|
64
|
-
);
|
|
65
|
-
} catch {
|
|
66
|
-
// Never let audit bookkeeping break enforcement. A rule shape the
|
|
67
|
-
// record transform rejects (or an IP range cidr-calc can't express)
|
|
68
|
-
// still blocks — it just describes itself without conditions.
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const conditions: MatchedRuleCondition[] = [];
|
|
73
|
-
for (const field of userScopeRecordFields) {
|
|
74
|
-
const condition = toCondition(field, scope[field]);
|
|
75
|
-
if (condition) {
|
|
76
|
-
conditions.push(condition);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return conditions;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Snapshot a matched rule for persistence alongside the request it acted on.
|
|
84
|
-
*
|
|
85
|
-
* Total function: every rule shape yields a value, so callers can inline it
|
|
86
|
-
* into a record literal without a try/catch of their own.
|
|
87
|
-
*/
|
|
88
|
-
export const describeMatchedRule = (rule: AccessRule): MatchedAccessRule => ({
|
|
89
|
-
ruleHash: makeAccessRuleHash(rule),
|
|
90
|
-
policyType: rule.type,
|
|
91
|
-
conditions: deriveConditions(rule),
|
|
92
|
-
...(rule.description !== undefined && { description: rule.description }),
|
|
93
|
-
...(rule.captchaType !== undefined && { captchaType: rule.captchaType }),
|
|
94
|
-
...(rule.deferToVerify !== undefined && {
|
|
95
|
-
deferToVerify: rule.deferToVerify,
|
|
96
|
-
}),
|
|
97
|
-
...(rule.groupId !== undefined && { ruleGroupId: rule.groupId }),
|
|
98
|
-
...(rule.clientId !== undefined && { clientId: rule.clientId }),
|
|
99
|
-
});
|
package/src/mongoose/.export.ts
DELETED
|
@@ -1,15 +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
|
-
export { accessRuleMongooseSchema } from "./mongooseRuleSchema.js";
|
|
@@ -1,75 +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 { SchemaDefinition } from "mongoose";
|
|
17
|
-
import type { AccessPolicy, PolicyScope } from "#policy/rule.js";
|
|
18
|
-
import type {
|
|
19
|
-
AccessRuleRecord,
|
|
20
|
-
UserAttributesRecord,
|
|
21
|
-
UserIpRecord,
|
|
22
|
-
UserScopeRecord,
|
|
23
|
-
} from "#policy/ruleRecord.js";
|
|
24
|
-
|
|
25
|
-
const userAttributesSchema: SchemaDefinition<UserAttributesRecord> = {
|
|
26
|
-
userId: { type: String, required: false },
|
|
27
|
-
ja4Hash: { type: String, required: false },
|
|
28
|
-
userAgent: { type: String, required: false },
|
|
29
|
-
headersHash: { type: String, required: false },
|
|
30
|
-
headHash: { type: String, required: false },
|
|
31
|
-
coords: { type: String, required: false },
|
|
32
|
-
countryCode: { type: String, required: false },
|
|
33
|
-
asn: { type: Number, required: false },
|
|
34
|
-
os: { type: String, required: false },
|
|
35
|
-
browser: { type: String, required: false },
|
|
36
|
-
headerMatch: { type: String, required: false },
|
|
37
|
-
headerName: { type: String, required: false },
|
|
38
|
-
headerValue: { type: String, required: false },
|
|
39
|
-
headerOperator: { type: String, required: false },
|
|
40
|
-
webBotAuthAgent: { type: String, required: false },
|
|
41
|
-
} satisfies AllKeys<UserAttributesRecord>;
|
|
42
|
-
|
|
43
|
-
const userIpSchema: SchemaDefinition<UserIpRecord> = {
|
|
44
|
-
ip: { type: String, required: false },
|
|
45
|
-
ipMask: { type: String, required: false },
|
|
46
|
-
} satisfies AllKeys<UserIpRecord>;
|
|
47
|
-
|
|
48
|
-
const userScopeSchema: SchemaDefinition<UserScopeRecord> = {
|
|
49
|
-
...userAttributesSchema,
|
|
50
|
-
...userIpSchema,
|
|
51
|
-
} satisfies Keys<UserScopeRecord>;
|
|
52
|
-
|
|
53
|
-
const policyScopeSchema: SchemaDefinition<PolicyScope> = {
|
|
54
|
-
clientId: { type: String, required: false },
|
|
55
|
-
} satisfies AllKeys<PolicyScope>;
|
|
56
|
-
|
|
57
|
-
const accessPolicySchema: SchemaDefinition<AccessPolicy> = {
|
|
58
|
-
type: { type: String, required: true },
|
|
59
|
-
captchaType: { type: String, required: false },
|
|
60
|
-
description: { type: String, required: false },
|
|
61
|
-
solvedImagesCount: { type: Number, required: false },
|
|
62
|
-
imageThreshold: { type: Number, required: false },
|
|
63
|
-
powDifficulty: { type: Number, required: false },
|
|
64
|
-
unsolvedImagesCount: { type: Number, required: false },
|
|
65
|
-
frictionlessScore: { type: Number, required: false },
|
|
66
|
-
deferToVerify: { type: Boolean, required: false },
|
|
67
|
-
messageKey: { type: String, required: false },
|
|
68
|
-
} satisfies AllKeys<AccessPolicy>;
|
|
69
|
-
|
|
70
|
-
export const accessRuleMongooseSchema: SchemaDefinition<AccessRuleRecord> = {
|
|
71
|
-
...accessPolicySchema,
|
|
72
|
-
...policyScopeSchema,
|
|
73
|
-
...userScopeSchema,
|
|
74
|
-
ruleGroupId: { type: String, required: false },
|
|
75
|
-
} satisfies Keys<AccessRuleRecord>;
|
package/src/redis/.export.ts
DELETED
|
@@ -1,17 +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
|
-
export { createRedisAccessRulesStorage } from "./redisRulesStorage.js";
|
|
16
|
-
|
|
17
|
-
export { accessRulesRedisIndex } from "./redisRuleIndex.js";
|
|
@@ -1,157 +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 { FtAggregateWithCursorOptions } from "@redis/search/dist/lib/commands/AGGREGATE_WITHCURSOR.js";
|
|
17
|
-
import type { RedisClientType } from "redis";
|
|
18
|
-
import { z } from "zod";
|
|
19
|
-
import { REDIS_QUERY_DIALECT } from "#policy/redis/reader/redisRulesQuery.js";
|
|
20
|
-
import {
|
|
21
|
-
REDIS_BATCH_SIZE,
|
|
22
|
-
parseRedisRecords,
|
|
23
|
-
} from "#policy/redis/redisClient.js";
|
|
24
|
-
import { ACCESS_RULES_REDIS_INDEX_NAME } from "#policy/redis/redisRuleIndex.js";
|
|
25
|
-
|
|
26
|
-
// Bounded-cap variant of `aggregateRedisKeys` for hot-path sub-queries.
|
|
27
|
-
// Uses FT.SEARCH NOCONTENT rather than FT.AGGREGATE + LOAD @__key so the
|
|
28
|
-
// reply is a flat `[total, key1, key2, ...]` list decoded by
|
|
29
|
-
// `SEARCH_NOCONTENT.transformReply`, never a nested tuples array. That
|
|
30
|
-
// side-steps a crash in `@redis/client` 5.x where a `null` tuple returned
|
|
31
|
-
// by the RediSearch coordinator (seen consistently on the split-query
|
|
32
|
-
// ja4Hash probes in prod — several thousand per hour of
|
|
33
|
-
// `TypeError: Cannot read properties of null (reading 'length')`) makes
|
|
34
|
-
// `transformTuplesReply` throw and the whole sub-query resolve empty.
|
|
35
|
-
// Cap fits in a single call because callers pass a small `maxKeys`
|
|
36
|
-
// (SPLIT_MAX_CANDIDATES_PER_SUB, currently 500) — no cursor needed.
|
|
37
|
-
export const searchRedisKeysBounded = async (
|
|
38
|
-
client: RedisClientType,
|
|
39
|
-
query: string,
|
|
40
|
-
maxKeys: number,
|
|
41
|
-
): Promise<string[]> => {
|
|
42
|
-
const reply = await client.ft.searchNoContent(
|
|
43
|
-
ACCESS_RULES_REDIS_INDEX_NAME,
|
|
44
|
-
query,
|
|
45
|
-
{
|
|
46
|
-
DIALECT: REDIS_QUERY_DIALECT,
|
|
47
|
-
LIMIT: { from: 0, size: maxKeys },
|
|
48
|
-
},
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
return reply.documents;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// aggregation is used for cases when we need to get "unlimited" search results
|
|
55
|
-
export const aggregateRedisKeys = async (
|
|
56
|
-
client: RedisClientType,
|
|
57
|
-
query: string,
|
|
58
|
-
logger: Logger,
|
|
59
|
-
batchHandler?: (keys: string[]) => Promise<void>,
|
|
60
|
-
maxKeys?: number,
|
|
61
|
-
): Promise<string[]> => {
|
|
62
|
-
const keyField = "__key";
|
|
63
|
-
|
|
64
|
-
const recordSchema = z.object({
|
|
65
|
-
// it's a reserved name for the record key
|
|
66
|
-
[keyField]: z.string(),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const foundKeys: string[] = [];
|
|
70
|
-
let stopRequested = false;
|
|
71
|
-
|
|
72
|
-
const addRecordKeys = async (records: object[]) => {
|
|
73
|
-
if (stopRequested) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const parsedRecords = parseRedisRecords(records, recordSchema, logger);
|
|
78
|
-
|
|
79
|
-
const recordKeys = parsedRecords.map((record) => record[keyField]);
|
|
80
|
-
|
|
81
|
-
if (batchHandler) {
|
|
82
|
-
await batchHandler(recordKeys);
|
|
83
|
-
} else {
|
|
84
|
-
if (
|
|
85
|
-
maxKeys !== undefined &&
|
|
86
|
-
foundKeys.length + recordKeys.length > maxKeys
|
|
87
|
-
) {
|
|
88
|
-
const remaining = Math.max(0, maxKeys - foundKeys.length);
|
|
89
|
-
foundKeys.push(...recordKeys.slice(0, remaining));
|
|
90
|
-
stopRequested = true;
|
|
91
|
-
|
|
92
|
-
logger.warn(() => ({
|
|
93
|
-
msg: "Redis aggregation candidate cap hit; truncating result set. This can suppress less-frequent rules and should be investigated.",
|
|
94
|
-
data: {
|
|
95
|
-
maxKeys,
|
|
96
|
-
query,
|
|
97
|
-
},
|
|
98
|
-
}));
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
foundKeys.push(...recordKeys);
|
|
103
|
-
|
|
104
|
-
logger.debug(() => ({
|
|
105
|
-
msg: "Processed aggregation batch",
|
|
106
|
-
data: {
|
|
107
|
-
size: recordKeys.length,
|
|
108
|
-
},
|
|
109
|
-
}));
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
await executeAggregation(
|
|
114
|
-
client,
|
|
115
|
-
query,
|
|
116
|
-
{
|
|
117
|
-
// #2 is a required option when the 'ismissing()' function is in the query body
|
|
118
|
-
DIALECT: REDIS_QUERY_DIALECT,
|
|
119
|
-
COUNT: REDIS_BATCH_SIZE,
|
|
120
|
-
LOAD: `@${keyField}`,
|
|
121
|
-
},
|
|
122
|
-
addRecordKeys,
|
|
123
|
-
() => stopRequested,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
return foundKeys;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const executeAggregation = async (
|
|
130
|
-
client: RedisClientType,
|
|
131
|
-
query: string,
|
|
132
|
-
aggregateOptions: FtAggregateWithCursorOptions,
|
|
133
|
-
handleBatch: (records: object[]) => Promise<void>,
|
|
134
|
-
shouldStop?: () => boolean,
|
|
135
|
-
): Promise<void> => {
|
|
136
|
-
const initialReply = await client.ft.aggregateWithCursor(
|
|
137
|
-
ACCESS_RULES_REDIS_INDEX_NAME,
|
|
138
|
-
query,
|
|
139
|
-
aggregateOptions,
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
await handleBatch(initialReply.results);
|
|
143
|
-
|
|
144
|
-
let cursor = initialReply.cursor;
|
|
145
|
-
|
|
146
|
-
while (0 !== cursor && !shouldStop?.()) {
|
|
147
|
-
const batchReply = await client.ft.cursorRead(
|
|
148
|
-
ACCESS_RULES_REDIS_INDEX_NAME,
|
|
149
|
-
cursor,
|
|
150
|
-
{ COUNT: aggregateOptions.COUNT },
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
await handleBatch(batchReply.results);
|
|
154
|
-
|
|
155
|
-
cursor = batchReply.cursor;
|
|
156
|
-
}
|
|
157
|
-
};
|