@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.
Files changed (65) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +2 -5
  2. package/.turbo/turbo-build$colon$tsc.log +14 -17
  3. package/.turbo/turbo-build.log +3 -9
  4. package/CHANGELOG.md +36 -0
  5. package/LICENSE +201 -0
  6. package/package.json +83 -81
  7. package/src/.export.ts +0 -65
  8. package/src/api/.export.ts +0 -25
  9. package/src/api/accessRulesApiClient.ts +0 -13
  10. package/src/api/delete/.export.ts +0 -18
  11. package/src/api/delete/deleteAllRules.ts +0 -47
  12. package/src/api/delete/deleteRuleGroups.ts +0 -96
  13. package/src/api/delete/deleteRules.ts +0 -87
  14. package/src/api/read/.export.ts +0 -25
  15. package/src/api/read/fetchRules.ts +0 -96
  16. package/src/api/read/findRuleIds.ts +0 -97
  17. package/src/api/read/getMissingIds.ts +0 -81
  18. package/src/api/ruleApiRoutes.ts +0 -146
  19. package/src/api/rulesApiClient.ts +0 -154
  20. package/src/api/write/.export.ts +0 -15
  21. package/src/api/write/insertRules.ts +0 -185
  22. package/src/api/write/rehashRules.ts +0 -85
  23. package/src/classifyBrowser.ts +0 -77
  24. package/src/classifyOs.ts +0 -74
  25. package/src/headerMatch.ts +0 -180
  26. package/src/matchedRule.ts +0 -99
  27. package/src/mongoose/.export.ts +0 -15
  28. package/src/mongoose/mongooseRuleSchema.ts +0 -75
  29. package/src/redis/.export.ts +0 -17
  30. package/src/redis/reader/redisAggregate.ts +0 -157
  31. package/src/redis/reader/redisRulesQuery.ts +0 -268
  32. package/src/redis/reader/redisRulesReader.ts +0 -738
  33. package/src/redis/reader/redisRulesSplitQuery.ts +0 -234
  34. package/src/redis/redisClient.ts +0 -125
  35. package/src/redis/redisRuleIndex.ts +0 -121
  36. package/src/redis/redisRulesStorage.ts +0 -68
  37. package/src/redis/redisRulesWriter.ts +0 -172
  38. package/src/rule.ts +0 -122
  39. package/src/ruleInput/.export.ts +0 -19
  40. package/src/ruleInput/policyInput.ts +0 -120
  41. package/src/ruleInput/ruleInput.ts +0 -123
  42. package/src/ruleInput/userScopeInput.ts +0 -142
  43. package/src/ruleRecord.ts +0 -77
  44. package/src/rulesStorage.ts +0 -90
  45. package/src/tests/blockingRuleShapes.unit.test.ts +0 -244
  46. package/src/tests/classifyBrowser.unit.test.ts +0 -138
  47. package/src/tests/classifyOs.unit.test.ts +0 -97
  48. package/src/tests/headerMatch.unit.test.ts +0 -350
  49. package/src/tests/insertRulesEndpoint.unit.test.ts +0 -89
  50. package/src/tests/matchedRule.unit.test.ts +0 -184
  51. package/src/tests/policyInput.unit.test.ts +0 -218
  52. package/src/tests/redis/deferredPoolClause.benchmark.ts +0 -249
  53. package/src/tests/redis/reader/redisAggregate.unit.test.ts +0 -121
  54. package/src/tests/redis/reader/redisRulesQuery.unit.test.ts +0 -349
  55. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +0 -297
  56. package/src/tests/redis/redisRulesReaderLoad.benchmark.integration.test.ts +0 -520
  57. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +0 -350
  58. package/src/tests/redis/redisRulesStorage.integration.test.ts +0 -1811
  59. package/src/tests/testLogger.ts +0 -38
  60. package/src/tests/transformRule.unit.test.ts +0 -345
  61. package/src/transformRule.ts +0 -133
  62. package/tsconfig.cjs.json +0 -41
  63. package/tsconfig.json +0 -47
  64. package/tsconfig.tsbuildinfo +0 -1
  65. package/tsconfig.types.json +0 -9
@@ -1,268 +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 {
16
- AccessPolicyType,
17
- GLOBAL_CLIENT_SCOPE_SENTINEL,
18
- type PolicyScope,
19
- type UserIp,
20
- type UserScope,
21
- } from "#policy/rule.js";
22
- import { userScopeSchema } from "#policy/ruleInput/userScopeInput.js";
23
- import {
24
- type AccessRulesFilter,
25
- FilterScopeMatch,
26
- } from "#policy/rulesStorage.js";
27
-
28
- type QueryBuilder = (value: unknown, scope: UserIp) => string;
29
-
30
- /**
31
- * Escapes special characters for Redis TAG field queries.
32
- * Redis TAG fields treat these characters as special and they must be escaped with a backslash.
33
- */
34
- const escapeTagValue = (value: string): string => {
35
- // Characters that need escaping in Redis TAG queries
36
- return value.replace(/([,.<>{}\[\]"':;!@#$%^&*()\-+=~|/\\])/g, "\\$1");
37
- };
38
-
39
- // #2 is a required option when the 'ismissing()' function is in the query body
40
- export const REDIS_QUERY_DIALECT = 2;
41
-
42
- const userIpQueries: Record<keyof UserIp, QueryBuilder> = {
43
- numericIp: (value, scope) => {
44
- if (undefined !== value) {
45
- return `( @numericIp:[${value} ${value}] | ( @numericIpMaskMin:[-inf ${value}] @numericIpMaskMax:[${value} +inf] ) )`;
46
- }
47
- // Only emit ismissing(@numericIp) if ranges are also not present
48
- if (
49
- scope.numericIpMaskMin === undefined &&
50
- scope.numericIpMaskMax === undefined
51
- ) {
52
- return "ismissing(@numericIp) ismissing(@numericIpMaskMin) ismissing(@numericIpMaskMax)";
53
- }
54
- // Else, let ranges handle it
55
- return "";
56
- },
57
- numericIpMaskMin: (value, scope) => {
58
- if (scope.numericIp !== undefined) {
59
- return ""; // handled by numericIp
60
- }
61
- // When all IP fields are undefined, numericIp handler already emits all ismissing clauses
62
- if (value === undefined && scope.numericIpMaskMax === undefined) {
63
- return "";
64
- }
65
- return value !== undefined
66
- ? `@numericIpMaskMin:[-inf ${value}]`
67
- : "ismissing(@numericIpMaskMin)";
68
- },
69
- numericIpMaskMax: (value, scope) => {
70
- if (scope.numericIp !== undefined) {
71
- return ""; // handled by numericIp
72
- }
73
- // When all IP fields are undefined, numericIp handler already emits all ismissing clauses
74
- if (value === undefined && scope.numericIpMaskMin === undefined) {
75
- return "";
76
- }
77
- return value !== undefined
78
- ? `@numericIpMaskMax:[${value} +inf]`
79
- : "ismissing(@numericIpMaskMax)";
80
- },
81
- };
82
-
83
- const getUserScopeQuery = (
84
- userScope: UserScope,
85
- FilterScopeMatchType: FilterScopeMatch | undefined,
86
- matchingFieldsOnly: boolean,
87
- ): string => {
88
- let scopeEntries = Object.entries(userScope) as Array<
89
- [keyof UserScope, unknown]
90
- >;
91
- let scopeJoinType = " ";
92
-
93
- // skip fields with undefined values if in greedy mode and set operator to OR
94
- if (FilterScopeMatchType === FilterScopeMatch.Greedy) {
95
- scopeEntries = scopeEntries.filter(
96
- ([_, value]) => value !== undefined,
97
- ) as Array<[keyof UserScope, unknown]>;
98
- scopeJoinType = " | ";
99
- }
100
-
101
- if (matchingFieldsOnly) {
102
- const scopeMap = new Map<keyof UserScope, unknown>(scopeEntries);
103
-
104
- // If numericIp is explicitly undefined, set both range fields to undefined
105
- if (scopeMap.has("numericIp") && scopeMap.get("numericIp") === undefined) {
106
- scopeMap.set("numericIpMaskMin", undefined);
107
- scopeMap.set("numericIpMaskMax", undefined);
108
- }
109
-
110
- // Ensure all expected fields are accounted for
111
- for (const name of Object.keys(userScopeSchema.shape) as Array<
112
- keyof UserScope
113
- >) {
114
- if (!scopeMap.has(name)) {
115
- scopeMap.set(name, undefined);
116
- }
117
- }
118
-
119
- scopeEntries = [...scopeMap.entries()];
120
- }
121
-
122
- const scopeObj = Object.fromEntries(scopeEntries) as Partial<UserScope>;
123
-
124
- return scopeEntries
125
- .map(([scopeFieldName, scopeFieldValue]) =>
126
- getUserScopeFieldQuery(
127
- scopeFieldName,
128
- scopeFieldValue,
129
- FilterScopeMatchType,
130
- scopeObj,
131
- ),
132
- )
133
- .filter(Boolean)
134
- .join(scopeJoinType);
135
- };
136
-
137
- // Fields that may contain special characters requiring escaping in Redis TAG queries
138
- const FIELDS_REQUIRING_ESCAPE: ReadonlySet<keyof UserScope> = new Set([
139
- "coords",
140
- ]);
141
-
142
- // Fields indexed as NUMERIC in RediSearch — must use range syntax `@x:[N N]`,
143
- // not the TAG syntax `@x:{N}`, or lookups silently return no results.
144
- const NUMERIC_FIELDS: ReadonlySet<keyof UserScope> = new Set(["asn"]);
145
-
146
- const getUserScopeFieldQuery = (
147
- fieldName: keyof UserScope,
148
- fieldValue: unknown,
149
- scopeMatch: FilterScopeMatch | undefined,
150
- fullScope: Partial<UserScope>,
151
- ): string => {
152
- if (fieldName in userIpQueries) {
153
- const queryBuilder = userIpQueries[fieldName as keyof UserIp];
154
-
155
- return queryBuilder(fieldValue, fullScope);
156
- }
157
-
158
- if (undefined === fieldValue) {
159
- return `ismissing(@${fieldName})`;
160
- }
161
-
162
- const stringValue = String(fieldValue);
163
-
164
- if (NUMERIC_FIELDS.has(fieldName)) {
165
- return `@${fieldName}:[${stringValue} ${stringValue}]`;
166
- }
167
-
168
- // Only escape fields that may contain special characters (like coords with JSON)
169
- const queryValue = FIELDS_REQUIRING_ESCAPE.has(fieldName)
170
- ? escapeTagValue(stringValue)
171
- : stringValue;
172
-
173
- return `@${fieldName}:{${queryValue}}`;
174
- };
175
-
176
- // Global rules are stamped with `@clientId:{global}` at write time so the
177
- // scope query can hit the clientId posting list instead of walking the
178
- // index for ismissing(). The `| ismissing(@clientId)` branch is a
179
- // transition safety net for rules written before the sentinel shipped —
180
- // rehash-all migrates them, but we don't gate on that ordering.
181
- const GLOBAL_MATCH_CLAUSE_INNER = `@clientId:{${GLOBAL_CLIENT_SCOPE_SENTINEL}} | ismissing(@clientId)`;
182
-
183
- const getPolicyScopeQuery = (
184
- policyScope: PolicyScope | undefined,
185
- scopeMatch: FilterScopeMatch | undefined,
186
- ): string => {
187
- const clientId = policyScope?.clientId;
188
-
189
- if ("string" === typeof clientId) {
190
- return FilterScopeMatch.Exact === scopeMatch
191
- ? `@clientId:{${clientId}}`
192
- : `( @clientId:{${clientId}} | ${GLOBAL_MATCH_CLAUSE_INNER} )`;
193
- }
194
-
195
- return FilterScopeMatch.Exact === scopeMatch
196
- ? `( ${GLOBAL_MATCH_CLAUSE_INNER} )`
197
- : "";
198
- };
199
-
200
- // Candidate-pool clause for the two hard-block callers.
201
- //
202
- // Verify (`includeDeferred`): a deferToVerify rule is skipped at request
203
- // time and enforced at verify, so it is a valid hard block whatever its
204
- // type — the pool must admit deferred Restrict rules too.
205
- //
206
- // Request time (middleware): deferred rules are never enforceable there,
207
- // so exclude them server-side rather than fetching and discarding them.
208
- // Rules with no `deferToVerify` field survive the negation (the field is
209
- // indexed INDEXMISSING).
210
- //
211
- // The union must be wrapped as a single group. `|` binds looser than the
212
- // implicit intersection, so an unwrapped `(@a:{x})|(@b:{y}) @ip:[…]`
213
- // parses as `@a:{x} OR (@b:{y} AND @ip:[…])` — the left side escapes the
214
- // scope and IP filters and matches every Block rule in the index. Both
215
- // `(@a:{x} | @b:{y})` and `((@a:{x})|(@b:{y}))` group correctly; this
216
- // form is the shorter one. Requires DIALECT 2, which every caller uses
217
- // (see REDIS_QUERY_DIALECT).
218
- export const getBlockPoolClause = (includeDeferred: boolean): string =>
219
- includeDeferred
220
- ? `(@type:{${AccessPolicyType.Block}} | @deferToVerify:{true})`
221
- : `@type:{${AccessPolicyType.Block}} -@deferToVerify:{true}`;
222
-
223
- /*
224
- * Search command example:
225
- *
226
- * ft.search index:test "( @clientId:{value} | ismissing(@clientId) )
227
- * (
228
- * ( @ip:[value] | ( @ipRangeMin:[-inf value] @ipRangeMax:[value +inf] ) ) |
229
- * @id:{value} | @ja4Fingerprint:{value} | headersFingerprint:{value}"
230
- * )
231
- * DIALECT 2 # must have when the ismissing() function in use
232
- * */
233
- export const getRulesRedisQuery = (
234
- filter: AccessRulesFilter,
235
- matchingFieldsOnly: boolean,
236
- ): string => {
237
- const { policyScope, userScope } = filter;
238
- const queryParts = [];
239
-
240
- if (filter.groupId) {
241
- queryParts.push(`@groupId:{${filter.groupId}}`);
242
- }
243
-
244
- if (filter.blockOnly) {
245
- queryParts.push(getBlockPoolClause(filter.includeDeferred === true));
246
- }
247
-
248
- const policyScopeQuery = getPolicyScopeQuery(
249
- policyScope,
250
- filter.policyScopeMatch,
251
- );
252
-
253
- if (policyScopeQuery) {
254
- queryParts.push(policyScopeQuery);
255
- }
256
-
257
- if (userScope && Object.keys(userScope).length > 0) {
258
- const userScopeFilter = getUserScopeQuery(
259
- userScope,
260
- filter.userScopeMatch,
261
- matchingFieldsOnly,
262
- );
263
-
264
- queryParts.push(`( ${userScopeFilter} )`);
265
- }
266
-
267
- return queryParts.length > 0 ? queryParts.join(" ") : "*";
268
- };