@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,350 +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 { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
16
- import type { Logger } from "@prosopo/logger";
17
- import {
18
- type RedisConnection,
19
- createTestRedisConnection,
20
- setupRedisIndex,
21
- } from "@prosopo/redis-client";
22
- import type { RedisClientType } from "redis";
23
- import { afterAll, beforeAll, describe, expect, test } from "vitest";
24
- import { RedisRulesReader } from "#policy/redis/reader/redisRulesReader.js";
25
- import {
26
- ACCESS_RULES_REDIS_INDEX_NAME,
27
- accessRulesRedisIndex,
28
- getAccessRuleRedisKey,
29
- } from "#policy/redis/redisRuleIndex.js";
30
- import { getRedisRuleValue } from "#policy/redis/redisRulesWriter.js";
31
- import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
32
- import { FilterScopeMatch } from "#policy/rulesStorage.js";
33
-
34
- // Production observation on pronode10 (2026-06-15):
35
- // - ~7,500 rules in the access-rule index
36
- // - greedy + FT.AGGREGATE pulled ~1,190 hashes per request and pegged
37
- // provider1 Node thread at ~125% CPU.
38
- // - Strict-match + server-side rank pulls at most SERVER_SIDE_RANK_TOP_N
39
- // hashes per request, so the cost is independent of total rule count.
40
- //
41
- // This benchmark seeds 10k rules with a realistic specificity distribution,
42
- // runs N findRules calls in matchingFieldsOnly=true mode, and asserts
43
- // per-call latency stays inside generous CI thresholds. The numbers below
44
- // are intentionally loose; healthy local runs typically come in 5-10x
45
- // under cap. The point is to catch regressions where someone re-introduces
46
- // HGETALL fanout into the hot path.
47
-
48
- const RULE_COUNT = 10_000;
49
- const QUERY_COUNT = 200;
50
-
51
- // CI thresholds. Local development on a warm redis container is steady
52
- // around p50=20ms / p99=25ms over 10k rules; thresholds set ~3-5x above
53
- // that to absorb noisy shared CI runners. If these start failing
54
- // regularly, look for changes that re-introduce per-rule HGETALL fanout
55
- // or break the SORTBY MAX short-circuit on the Redis side.
56
- const P50_LATENCY_MS = 80;
57
- const P99_LATENCY_MS = 250;
58
-
59
- // Build a deterministic distribution of rules that mirrors production
60
- // roughly:
61
- // ~70% low-specificity (1 field, e.g. ja4-only or country-only)
62
- // ~20% medium (2-3 fields, typical anomaly detector emission)
63
- // ~10% high (4+ fields, manual portal rules with full scope)
64
- const buildBenchmarkRule = (i: number): AccessRule => {
65
- const bucket = i % 10;
66
- const ja4 = `t13d_bench_${(i % 50).toString().padStart(3, "0")}`;
67
- const asn = 1000 + (i % 500);
68
- // description is included in the content hash that generates the
69
- // rule's Redis key, so a unique description ensures every synthetic
70
- // rule lands on its own key. It doesn't affect specificity scoring
71
- // or the strict-match filter.
72
- const description = `bench-rule-${i}`;
73
-
74
- if (bucket < 7) {
75
- // low specificity — anomaly detector style
76
- const variant = i % 3;
77
- if (variant === 0)
78
- return { type: AccessPolicyType.Block, description, ja4Hash: ja4 };
79
- if (variant === 1)
80
- return {
81
- type: AccessPolicyType.Block,
82
- description,
83
- asn,
84
- };
85
- return {
86
- type: AccessPolicyType.Restrict,
87
- description,
88
- countryCode: `C${i % 30}`,
89
- };
90
- }
91
-
92
- if (bucket < 9) {
93
- // medium specificity — client-scoped + 1-2 fields
94
- return {
95
- type: AccessPolicyType.Block,
96
- description,
97
- clientId: `client${i % 20}`,
98
- ja4Hash: ja4,
99
- ...(i % 2 === 0 && { userAgentHash: `ua${i % 100}` }),
100
- };
101
- }
102
-
103
- // high specificity — full scope portal rules
104
- return {
105
- type: AccessPolicyType.Block,
106
- description,
107
- clientId: `client${i % 20}`,
108
- ja4Hash: ja4,
109
- userAgentHash: `ua${i % 100}`,
110
- headersHash: `hh${i % 200}`,
111
- countryCode: `C${i % 30}`,
112
- };
113
- };
114
-
115
- // Deterministic request-scope generator. Every request populates the
116
- // full set of user-scope fields the rules use, so under strict-match
117
- // semantics rules whose populated fields are a subset of the request
118
- // scope can actually apply. Mirrors a fully-instrumented production
119
- // request (ja4 + UA + headers + country + asn all known).
120
- const buildBenchmarkRequest = (i: number) => {
121
- return {
122
- clientId: `client${i % 20}`,
123
- scope: {
124
- ja4Hash: `t13d_bench_${(i % 50).toString().padStart(3, "0")}`,
125
- userAgentHash: `ua${i % 100}`,
126
- headersHash: `hh${i % 200}`,
127
- countryCode: `C${i % 30}`,
128
- asn: 1000 + (i % 500),
129
- },
130
- };
131
- };
132
-
133
- const percentile = (sortedMs: number[], q: number): number => {
134
- if (sortedMs.length === 0) return 0;
135
- const idx = Math.min(
136
- sortedMs.length - 1,
137
- Math.floor((sortedMs.length - 1) * q),
138
- );
139
- const v = sortedMs[idx];
140
- return v ?? 0;
141
- };
142
-
143
- describe("redisRulesReader ranked-path benchmark", () => {
144
- let redisConnection: RedisConnection;
145
- let redisClient: RedisClientType;
146
- let reader: RedisRulesReader;
147
-
148
- // Every member is a no-op sink, except `with`, which has to return a child
149
- // logger — setupRedisIndex calls `logger.with({ name })` and logs through
150
- // the result. Returning the proxy itself keeps the child a sink too.
151
- const mockLogger = new Proxy(
152
- {},
153
- {
154
- get: (_target, property) =>
155
- property === "with" ? () => mockLogger : () => {},
156
- },
157
- ) as unknown as Logger;
158
-
159
- // Track every key we insert so cleanup deletes only our rules,
160
- // leaving any other test suite's data and the shared index intact.
161
- const seededKeys: string[] = [];
162
-
163
- beforeAll(async () => {
164
- redisConnection = createTestRedisConnection();
165
- redisClient = await setupRedisIndex(
166
- redisConnection,
167
- accessRulesRedisIndex,
168
- mockLogger,
169
- ).getClient();
170
- reader = new RedisRulesReader(redisClient, mockLogger);
171
-
172
- // Seed RULE_COUNT rules in batches of 1000 — same chunk size the
173
- // production writer uses, mirrors realistic insert behaviour.
174
- const rules: AccessRule[] = [];
175
- for (let i = 0; i < RULE_COUNT; i++) {
176
- rules.push(buildBenchmarkRule(i));
177
- }
178
- await executeBatchesSequentially(
179
- chunkIntoBatches(rules, 1000),
180
- async (batch) => {
181
- const multi = redisClient.multi();
182
- for (const rule of batch) {
183
- const key = getAccessRuleRedisKey(rule);
184
- seededKeys.push(key);
185
- multi.hSet(key, getRedisRuleValue(rule));
186
- }
187
- await multi.exec();
188
- },
189
- );
190
-
191
- const indexInfo = await redisClient.ft.info(ACCESS_RULES_REDIS_INDEX_NAME);
192
- // Description is unique per rule so every rule should land on
193
- // its own key; allow a 10% margin for any incidental dedupe.
194
- expect(indexInfo.num_docs).toBeGreaterThan(RULE_COUNT * 0.9);
195
- }, 120_000);
196
-
197
- afterAll(async () => {
198
- // Delete only the keys this suite inserted; do not flushAll
199
- // because we share the redis instance (and the index) with the
200
- // other integration test file.
201
- await executeBatchesSequentially(
202
- chunkIntoBatches(seededKeys, 1000),
203
- async (batch) => {
204
- const multi = redisClient.multi();
205
- for (const key of batch) {
206
- multi.del(key);
207
- }
208
- await multi.exec();
209
- },
210
- );
211
- });
212
-
213
- test(`ranked findRules p50/p99 over ${QUERY_COUNT} queries against ${RULE_COUNT} rules`, async () => {
214
- // warm up — first call pays for index page-in
215
- await reader.findRules(
216
- {
217
- policyScope: { clientId: "warmup" },
218
- policyScopeMatch: FilterScopeMatch.Greedy,
219
- userScope: { ja4Hash: "warmup" },
220
- userScopeMatch: FilterScopeMatch.Greedy,
221
- },
222
- true,
223
- );
224
-
225
- const samples: number[] = [];
226
- for (let i = 0; i < QUERY_COUNT; i++) {
227
- const { clientId, scope } = buildBenchmarkRequest(i);
228
- const start = performance.now();
229
- await reader.findRules(
230
- {
231
- policyScope: { clientId },
232
- policyScopeMatch: FilterScopeMatch.Greedy,
233
- userScope: scope,
234
- userScopeMatch: FilterScopeMatch.Greedy,
235
- },
236
- true,
237
- );
238
- samples.push(performance.now() - start);
239
- }
240
-
241
- samples.sort((a, b) => a - b);
242
- const p50 = percentile(samples, 0.5);
243
- const p99 = percentile(samples, 0.99);
244
- const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
245
-
246
- console.log(
247
- `ranked findRules: avg=${avg.toFixed(2)}ms p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms n=${samples.length}`,
248
- );
249
-
250
- expect(p50).toBeLessThan(P50_LATENCY_MS);
251
- expect(p99).toBeLessThan(P99_LATENCY_MS);
252
- }, 120_000);
253
-
254
- test("split findRules returns every applicable candidate (no server-side rank cap)", async () => {
255
- // The old FT.AGGREGATE ranker capped candidates at
256
- // SERVER_SIDE_RANK_TOP_N=20 which silently dropped
257
- // specific-IP Restrict rules when a tenant had many
258
- // higher-specificity irrelevant rules — the 2026-07-10
259
- // Twickets regression. The split path per-probe uses
260
- // discriminating posting lists, so each probe returns only
261
- // rules that actually match the probed field. The union of
262
- // all probes has no artificial size cap; the only bound is
263
- // SPLIT_MAX_CANDIDATES_PER_SUB (500) per sub-query, which
264
- // only fires on pathological rule-set shapes.
265
- const { clientId, scope } = buildBenchmarkRequest(0);
266
- const results = await reader.findRules(
267
- {
268
- policyScope: { clientId },
269
- policyScopeMatch: FilterScopeMatch.Greedy,
270
- userScope: scope,
271
- userScopeMatch: FilterScopeMatch.Greedy,
272
- },
273
- true,
274
- );
275
- // Must return something for a fully-populated request.
276
- expect(results.length).toBeGreaterThan(0);
277
- }, 60_000);
278
-
279
- test("HGETALL-free: ranked path returns full rules without per-rule fanout", async () => {
280
- // The benefit of the ranked path in production is *not* raw
281
- // localhost latency (the strict-match index walk is heavier
282
- // than a greedy FT.SEARCH on a quiet box). It's that the
283
- // ranked path returns full rule bodies inside the single
284
- // FT.AGGREGATE reply, with no follow-up HGETALL fanout. In
285
- // production each HGETALL costs ~0.5ms of network RTT over
286
- // the Docker bridge, so cutting hundreds of round trips per
287
- // request is the win that doesn't appear in a localhost
288
- // timing test.
289
- //
290
- // This test stands in for that: confirm the ranked path
291
- // produces fully-populated AccessRule objects from one
292
- // aggregate call (no separate hash fetches needed).
293
- const { clientId, scope } = buildBenchmarkRequest(0);
294
- const ranked = await reader.findRules(
295
- {
296
- policyScope: { clientId },
297
- policyScopeMatch: FilterScopeMatch.Greedy,
298
- userScope: scope,
299
- userScopeMatch: FilterScopeMatch.Greedy,
300
- },
301
- true,
302
- );
303
-
304
- expect(ranked.length).toBeGreaterThan(0);
305
- // Every returned record must have the policy fields populated —
306
- // proves the LOAD pipeline includes them rather than relying
307
- // on a follow-up HGETALL.
308
- for (const rule of ranked) {
309
- expect(rule.type).toBeDefined();
310
- }
311
- }, 60_000);
312
-
313
- test("ranked findRules returns rules in specificity-descending order", async () => {
314
- // A request that matches the high-specificity bucket should
315
- // surface the rule with the most populated fields first.
316
- const { clientId, scope } = buildBenchmarkRequest(9);
317
- const results = await reader.findRules(
318
- {
319
- policyScope: { clientId },
320
- policyScopeMatch: FilterScopeMatch.Greedy,
321
- userScope: scope,
322
- userScopeMatch: FilterScopeMatch.Greedy,
323
- },
324
- true,
325
- );
326
-
327
- if (results.length === 0) return;
328
- const top = results[0];
329
- if (!top) return;
330
-
331
- // The top rule must have at least as many populated user-scope
332
- // fields as any other returned rule.
333
- const countFields = (r: AccessRule): number =>
334
- (r.userId ? 1 : 0) +
335
- (r.ja4Hash ? 1 : 0) +
336
- (r.headersHash ? 1 : 0) +
337
- (r.userAgentHash ? 1 : 0) +
338
- (r.headHash ? 1 : 0) +
339
- (r.coords ? 1 : 0) +
340
- (r.countryCode ? 1 : 0) +
341
- (r.asn !== undefined ? 1 : 0) +
342
- (r.clientId ? 1 : 0) +
343
- (r.numericIp !== undefined || r.numericIpMaskMin !== undefined ? 1 : 0);
344
-
345
- const topScore = countFields(top);
346
- for (const r of results) {
347
- expect(countFields(r)).toBeLessThanOrEqual(topScore);
348
- }
349
- }, 60_000);
350
- });