@prosopo/user-access-policy 3.12.1 → 3.12.3

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 (27) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +4 -4
  2. package/.turbo/turbo-build$colon$tsc.log +14 -14
  3. package/.turbo/turbo-build.log +5 -5
  4. package/CHANGELOG.md +35 -0
  5. package/dist/cjs/redis/reader/redisRulesReader.cjs +42 -11
  6. package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +5 -3
  7. package/dist/redis/reader/redisRulesReader.d.ts +1 -1
  8. package/dist/redis/reader/redisRulesReader.d.ts.map +1 -1
  9. package/dist/redis/reader/redisRulesReader.js +43 -12
  10. package/dist/redis/reader/redisRulesReader.js.map +1 -1
  11. package/dist/redis/reader/redisRulesSplitQuery.d.ts +3 -0
  12. package/dist/redis/reader/redisRulesSplitQuery.d.ts.map +1 -1
  13. package/dist/redis/reader/redisRulesSplitQuery.js +6 -4
  14. package/dist/redis/reader/redisRulesSplitQuery.js.map +1 -1
  15. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js +27 -1
  16. package/dist/tests/redis/reader/redisRulesSplitQuery.unit.test.js.map +1 -1
  17. package/dist/tests/redis/redisRulesReaderRank.benchmark.integration.test.js +1 -2
  18. package/dist/tests/redis/redisRulesReaderRank.benchmark.integration.test.js.map +1 -1
  19. package/dist/tests/redis/redisRulesStorage.integration.test.js +46 -0
  20. package/dist/tests/redis/redisRulesStorage.integration.test.js.map +1 -1
  21. package/package.json +8 -8
  22. package/src/redis/reader/redisRulesReader.ts +78 -28
  23. package/src/redis/reader/redisRulesSplitQuery.ts +31 -6
  24. package/src/tests/redis/reader/redisRulesSplitQuery.unit.test.ts +76 -1
  25. package/src/tests/redis/redisRulesReaderRank.benchmark.integration.test.ts +12 -4
  26. package/src/tests/redis/redisRulesStorage.integration.test.ts +100 -0
  27. package/tsconfig.tsbuildinfo +1 -1
@@ -1629,6 +1629,106 @@ describe("redisAccessRulesStorage", () => {
1629
1629
  ),
1630
1630
  ).toBe(true);
1631
1631
  });
1632
+
1633
+ test("specific-IP Restrict rule survives when many higher-specificity irrelevant rules co-exist (2026-07-10 Twickets regression)", async () => {
1634
+ // Regression guard for the 2026-07-10 Twickets incident: a
1635
+ // portal-authored Restrict rule with scope
1636
+ // `clientId + numericIp` (specificity 2, severity 0) was
1637
+ // silently dropped from the frictionless access-policy lookup
1638
+ // when the tenant had many higher-specificity irrelevant
1639
+ // rules on the same clientId. The old FT.AGGREGATE ranker
1640
+ // used for non-block lookups capped candidates at top-20 by
1641
+ // populated-field count; those 20 slots got filled by rules
1642
+ // with clientId + numericIp + numericIpMaskMin (SIMD_REPLAY
1643
+ // v6 shape, specificity 3, severity 1) or
1644
+ // clientId + ja4Hash + coords (SUDDEN_VOLUME_INCREASE shape,
1645
+ // specificity 3, severity 1) that all matched the greedy
1646
+ // `ismissing(@headHash) | ismissing(@coords) | ismissing(@headersHash)`
1647
+ // disjunction. None of them applied to the request under
1648
+ // `ruleApplies` — they were emitted from other users'
1649
+ // activity — so the JS-side ranker returned [] and the
1650
+ // frictionless decision fell through to `default_pow`.
1651
+ //
1652
+ // The split-query path can't hit that failure: each probe
1653
+ // hits a discriminating posting list, so the ip:exact probe
1654
+ // returns exactly the rules that literally match this IP.
1655
+ const clientId = getUniqueString();
1656
+
1657
+ // The rule that must survive: a portal "Too Many Requests"
1658
+ // Restrict/image rule scoped only to (clientId, numericIp).
1659
+ const targetIp = 1376899398n; // 82.17.209.70 — Twickets rule
1660
+ const targetRule: AccessRule = {
1661
+ type: AccessPolicyType.Restrict,
1662
+ clientId,
1663
+ numericIp: targetIp,
1664
+ description: "Too Many Requests",
1665
+ };
1666
+
1667
+ // 40 irrelevant Block rules with higher specificity than
1668
+ // the target rule. Two shapes drawn from the live Twickets
1669
+ // tenant that dominated the top-20:
1670
+ //
1671
+ // (a) SIMD_REPLAY v6-style — clientId + numericIp +
1672
+ // numericIpMaskMin. These have a *different* numericIp
1673
+ // from the request, so the exact-IP probe skips them.
1674
+ // (b) SUDDEN_VOLUME_INCREASE-style — clientId + ja4Hash +
1675
+ // coords. Different ja4Hash from the request so those
1676
+ // probes miss them too.
1677
+ //
1678
+ // Under the old ranker every one of these would fill the top
1679
+ // slots and push the target rule off the end.
1680
+ const irrelevantRules: AccessRule[] = [];
1681
+ for (let i = 0; i < 20; i++) {
1682
+ irrelevantRules.push({
1683
+ type: AccessPolicyType.Block,
1684
+ clientId,
1685
+ numericIp: BigInt(2000000000 + i),
1686
+ numericIpMaskMin: BigInt(2000000000 + i),
1687
+ numericIpMaskMax: BigInt(2000000000 + i),
1688
+ description: "SIMD_REPLAY",
1689
+ });
1690
+ }
1691
+ for (let i = 0; i < 20; i++) {
1692
+ irrelevantRules.push({
1693
+ type: AccessPolicyType.Block,
1694
+ clientId,
1695
+ ja4Hash: `t13d_other_${i}`,
1696
+ coords: `[[[${100 + i},${200 + i}]]]`,
1697
+ description: "SUDDEN_VOLUME_INCREASE",
1698
+ });
1699
+ }
1700
+
1701
+ await insertRules([targetRule, ...irrelevantRules]);
1702
+
1703
+ // Frictionless access-policy lookup shape — greedy match,
1704
+ // matchingFieldsOnly=true, no blockOnly.
1705
+ const found = await accessRulesReader.findRules(
1706
+ {
1707
+ policyScope: { clientId },
1708
+ policyScopeMatch: FilterScopeMatch.Greedy,
1709
+ userScope: {
1710
+ numericIp: targetIp,
1711
+ ja4Hash: "t13d1516h2_request_ja4",
1712
+ userAgentHash: "sha256:request_ua",
1713
+ userId: getUniqueString(),
1714
+ },
1715
+ userScopeMatch: FilterScopeMatch.Greedy,
1716
+ },
1717
+ true, // matchingFieldsOnly — production hot path
1718
+ );
1719
+
1720
+ // The target rule MUST come back. Under the old ranker it
1721
+ // was truncated; under the split path the ip:exact probe
1722
+ // returns exactly this one rule, so it can't be crowded
1723
+ // out by irrelevant candidates from other probes.
1724
+ const targetFound = found.find(
1725
+ (r) =>
1726
+ r.type === AccessPolicyType.Restrict &&
1727
+ r.numericIp === targetIp &&
1728
+ r.description === "Too Many Requests",
1729
+ );
1730
+ expect(targetFound).toBeDefined();
1731
+ });
1632
1732
  });
1633
1733
 
1634
1734
  afterAll(async () => {