@prosopo/user-access-policy 3.12.14 → 3.12.16

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.
@@ -0,0 +1,99 @@
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
+ });
@@ -0,0 +1,184 @@
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 { CaptchaType, MatchedAccessRuleSchema } from "@prosopo/types";
16
+ import { Address4 } from "ip-address";
17
+ import { describe, expect, it } from "vitest";
18
+ import { describeMatchedRule } from "#policy/matchedRule.js";
19
+ import { AccessPolicyType, type AccessRule } from "#policy/rule.js";
20
+ import { userScopeRecordFields } from "#policy/ruleRecord.js";
21
+ import { makeAccessRuleHash } from "#policy/transformRule.js";
22
+
23
+ describe("describeMatchedRule", () => {
24
+ it("carries the policy half of the rule", () => {
25
+ const rule: AccessRule = {
26
+ type: AccessPolicyType.Restrict,
27
+ captchaType: CaptchaType.image,
28
+ description: "6 rounds for this ASN",
29
+ deferToVerify: true,
30
+ clientId: "site-key",
31
+ groupId: "group-1",
32
+ ja4Hash: "t13d1516h2_8daaf6152771_b186095e22b6",
33
+ };
34
+
35
+ expect(describeMatchedRule(rule)).toEqual({
36
+ ruleHash: makeAccessRuleHash(rule),
37
+ policyType: AccessPolicyType.Restrict,
38
+ captchaType: CaptchaType.image,
39
+ description: "6 rounds for this ASN",
40
+ deferToVerify: true,
41
+ clientId: "site-key",
42
+ // `groupId` on the rule is `ruleGroupId` everywhere else — the
43
+ // portal joins the audit row back to its Access Control entry on
44
+ // this, so the rename has to happen here.
45
+ ruleGroupId: "group-1",
46
+ conditions: [
47
+ {
48
+ field: "ja4Hash",
49
+ value: "t13d1516h2_8daaf6152771_b186095e22b6",
50
+ },
51
+ ],
52
+ });
53
+ });
54
+
55
+ it("omits policy fields the rule did not set rather than emitting undefined", () => {
56
+ const described = describeMatchedRule({ type: AccessPolicyType.Block });
57
+
58
+ expect(described).toEqual({
59
+ ruleHash: makeAccessRuleHash({ type: AccessPolicyType.Block }),
60
+ policyType: AccessPolicyType.Block,
61
+ conditions: [],
62
+ });
63
+ expect(Object.keys(described)).not.toContain("description");
64
+ expect(Object.keys(described)).not.toContain("ruleGroupId");
65
+ });
66
+
67
+ it("reports a single IP in the form the operator typed, not the numeric one", () => {
68
+ const { conditions } = describeMatchedRule({
69
+ type: AccessPolicyType.Block,
70
+ numericIp: new Address4("192.0.2.44").bigInt(),
71
+ });
72
+
73
+ expect(conditions).toEqual([{ field: "ip", value: "192.0.2.44" }]);
74
+ });
75
+
76
+ it("collapses an IP range back to its CIDR", () => {
77
+ const { conditions } = describeMatchedRule({
78
+ type: AccessPolicyType.Block,
79
+ numericIpMaskMin: new Address4("198.51.100.64").bigInt(),
80
+ numericIpMaskMax: new Address4("198.51.100.127").bigInt(),
81
+ });
82
+
83
+ expect(conditions).toEqual([
84
+ { field: "ipMask", value: "198.51.100.64/26" },
85
+ ]);
86
+ });
87
+
88
+ it("stringifies a numeric asn", () => {
89
+ const { conditions } = describeMatchedRule({
90
+ type: AccessPolicyType.Block,
91
+ asn: 205016,
92
+ });
93
+
94
+ expect(conditions).toEqual([{ field: "asn", value: "205016" }]);
95
+ });
96
+
97
+ it("reports every populated scope field, in record vocabulary", () => {
98
+ const { conditions } = describeMatchedRule({
99
+ type: AccessPolicyType.Block,
100
+ userId: "user",
101
+ ja4Hash: "ja4",
102
+ headersHash: "headers",
103
+ // Hashed on the rule, but surfaces under the record-side name the
104
+ // portal labels conditions with.
105
+ userAgentHash: "ua",
106
+ headHash: "head",
107
+ coords: "[[1,2]]",
108
+ countryCode: "GB",
109
+ asn: 1,
110
+ os: "macos",
111
+ numericIp: new Address4("203.0.113.5").bigInt(),
112
+ });
113
+
114
+ expect(conditions.map((condition) => condition.field)).toEqual([
115
+ "userId",
116
+ "ja4Hash",
117
+ "headersHash",
118
+ "userAgent",
119
+ "headHash",
120
+ "coords",
121
+ "countryCode",
122
+ "asn",
123
+ "os",
124
+ "ip",
125
+ ]);
126
+ // Every emitted field is one the portal knows how to label.
127
+ for (const condition of conditions) {
128
+ expect(userScopeRecordFields).toContain(condition.field);
129
+ }
130
+ });
131
+
132
+ it("truncates an oversized coords polygon and flags it", () => {
133
+ const coords = JSON.stringify([Array.from({ length: 200 }, () => [1, 2])]);
134
+ const { conditions } = describeMatchedRule({
135
+ type: AccessPolicyType.Block,
136
+ coords,
137
+ });
138
+
139
+ const [condition] = conditions;
140
+ expect(condition?.truncated).toBe(true);
141
+ expect(condition?.value.length).toBe(257);
142
+ expect(coords.startsWith(condition?.value.slice(0, -1) ?? "")).toBe(true);
143
+ });
144
+
145
+ it("leaves a value at the cap untruncated", () => {
146
+ const coords = "x".repeat(256);
147
+ const { conditions } = describeMatchedRule({
148
+ type: AccessPolicyType.Block,
149
+ coords,
150
+ });
151
+
152
+ expect(conditions).toEqual([{ field: "coords", value: coords }]);
153
+ });
154
+
155
+ it("still describes a rule whose scope cannot be transformed", () => {
156
+ // An inverted mask range makes the record transform throw. Enforcement
157
+ // must not care: the block still happens, the snapshot just lacks
158
+ // conditions.
159
+ const rule: AccessRule = {
160
+ type: AccessPolicyType.Block,
161
+ description: "unrepresentable range",
162
+ numericIpMaskMin: new Address4("198.51.100.200").bigInt(),
163
+ numericIpMaskMax: new Address4("198.51.100.1").bigInt(),
164
+ };
165
+
166
+ expect(() => describeMatchedRule(rule)).not.toThrow();
167
+ expect(describeMatchedRule(rule)).toEqual({
168
+ ruleHash: makeAccessRuleHash(rule),
169
+ policyType: AccessPolicyType.Block,
170
+ description: "unrepresentable range",
171
+ conditions: [],
172
+ });
173
+ });
174
+
175
+ it("produces a value the persisted schema accepts", () => {
176
+ const described = describeMatchedRule({
177
+ type: AccessPolicyType.Restrict,
178
+ captchaType: CaptchaType.pow,
179
+ countryCode: "CN",
180
+ });
181
+
182
+ expect(MatchedAccessRuleSchema.safeParse(described).success).toBe(true);
183
+ });
184
+ });