@prosopo/user-access-policy 3.14.14 → 3.14.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.
- package/.turbo/turbo-build$colon$cjs.log +3 -6
- package/.turbo/turbo-build$colon$tsc.log +14 -17
- package/.turbo/turbo-build.log +4 -10
- package/CHANGELOG.md +37 -0
- package/LICENSE +201 -0
- package/dist/cjs/ruleInput/policyInput.cjs +1 -1
- package/dist/ruleInput/policyInput.d.ts +2 -2
- package/dist/ruleInput/policyInput.d.ts.map +1 -1
- package/dist/ruleInput/policyInput.js +2 -2
- package/dist/ruleInput/policyInput.js.map +1 -1
- 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
|
@@ -1,172 +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 type { RedisClientType } from "redis";
|
|
18
|
-
import { REDIS_BATCH_SIZE } from "#policy/redis/redisClient.js";
|
|
19
|
-
import { type AccessRule, GLOBAL_CLIENT_SCOPE_SENTINEL } from "#policy/rule.js";
|
|
20
|
-
import type {
|
|
21
|
-
AccessRuleEntry,
|
|
22
|
-
AccessRulesWriter,
|
|
23
|
-
} from "#policy/rulesStorage.js";
|
|
24
|
-
import {
|
|
25
|
-
ACCESS_RULE_REDIS_KEY_PREFIX,
|
|
26
|
-
getAccessRuleRedisKey,
|
|
27
|
-
} from "./redisRuleIndex.js";
|
|
28
|
-
|
|
29
|
-
export class RedisRulesWriter implements AccessRulesWriter {
|
|
30
|
-
constructor(
|
|
31
|
-
private readonly client: RedisClientType,
|
|
32
|
-
private readonly logger: Logger,
|
|
33
|
-
) {}
|
|
34
|
-
|
|
35
|
-
async insertRules(ruleEntries: AccessRuleEntry[]): Promise<string[]> {
|
|
36
|
-
const entryBatches = chunkIntoBatches(ruleEntries, REDIS_BATCH_SIZE);
|
|
37
|
-
|
|
38
|
-
const keyBatches = await executeBatchesSequentially(
|
|
39
|
-
entryBatches,
|
|
40
|
-
async (entriesBatch) => this.insertRuleEntries(entriesBatch),
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
return keyBatches.flatMap((ruleKey) =>
|
|
44
|
-
ruleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async deleteRules(ruleIds: string[]): Promise<void> {
|
|
49
|
-
const ruleKeys = ruleIds.map(
|
|
50
|
-
(ruleId) => ACCESS_RULE_REDIS_KEY_PREFIX + ruleId,
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const keyBatches = chunkIntoBatches(ruleKeys, REDIS_BATCH_SIZE);
|
|
54
|
-
|
|
55
|
-
await executeBatchesSequentially(keyBatches, async (keysBatch) => {
|
|
56
|
-
const queries = this.client.multi();
|
|
57
|
-
|
|
58
|
-
for (const ruleKey of keysBatch) {
|
|
59
|
-
queries.del(ruleKey);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
await queries.exec();
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async deleteAllRules(): Promise<number> {
|
|
67
|
-
let cursor = "0";
|
|
68
|
-
let total = 0;
|
|
69
|
-
|
|
70
|
-
do {
|
|
71
|
-
const reply = await this.client.scan(cursor, {
|
|
72
|
-
MATCH: `${ACCESS_RULE_REDIS_KEY_PREFIX}*`,
|
|
73
|
-
COUNT: REDIS_BATCH_SIZE,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const ids = reply.keys.map((key) =>
|
|
77
|
-
key.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
|
|
78
|
-
);
|
|
79
|
-
await this.deleteRules(ids);
|
|
80
|
-
|
|
81
|
-
total += ids.length;
|
|
82
|
-
cursor = reply.cursor;
|
|
83
|
-
} while ("0" !== cursor);
|
|
84
|
-
|
|
85
|
-
return total;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
protected async insertRuleEntries(
|
|
89
|
-
ruleEntries: AccessRuleEntry[],
|
|
90
|
-
): Promise<string[]> {
|
|
91
|
-
const queries = this.client.multi();
|
|
92
|
-
|
|
93
|
-
const ruleKeys = ruleEntries.map((ruleEntry) => {
|
|
94
|
-
const { rule, expiresUnixTimestamp } = ruleEntry;
|
|
95
|
-
|
|
96
|
-
const ruleKey = getAccessRuleRedisKey(rule);
|
|
97
|
-
const ruleValue = getRedisRuleValue(rule);
|
|
98
|
-
|
|
99
|
-
queries.hSet(ruleKey, ruleValue);
|
|
100
|
-
|
|
101
|
-
if (expiresUnixTimestamp) {
|
|
102
|
-
// Redis expireAt expects seconds. Validate that timestamp is in seconds, not milliseconds.
|
|
103
|
-
// Unix timestamps in milliseconds (since 1970) are > 10 billion
|
|
104
|
-
// Unix timestamps in seconds won't reach 10 billion until year 2286
|
|
105
|
-
const MILLISECOND_THRESHOLD = 10_000_000_000;
|
|
106
|
-
if (expiresUnixTimestamp > MILLISECOND_THRESHOLD) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
`Invalid expiry timestamp: ${expiresUnixTimestamp}. Timestamp must be in seconds, not milliseconds.`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
queries.expireAt(ruleKey, expiresUnixTimestamp);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return ruleKey;
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
await queries.exec();
|
|
118
|
-
|
|
119
|
-
return ruleKeys;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export const getRedisRuleValue = (rule: AccessRule): Record<string, string> => {
|
|
124
|
-
const record: Record<string, string> = {};
|
|
125
|
-
for (const [key, value] of Object.entries(rule)) {
|
|
126
|
-
if (value === undefined) {
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
record[key] = String(value);
|
|
130
|
-
}
|
|
131
|
-
// Global rules used to be stored with no clientId field at all and looked
|
|
132
|
-
// up via `ismissing(@clientId)`. Stamp the sentinel so the read path can
|
|
133
|
-
// probe `@clientId:{global}` instead — a posting-list intersection is
|
|
134
|
-
// orders of magnitude cheaper than ismissing over a large rule set.
|
|
135
|
-
// Rules with a real clientId are untouched.
|
|
136
|
-
if (record.clientId === undefined) {
|
|
137
|
-
record.clientId = GLOBAL_CLIENT_SCOPE_SENTINEL;
|
|
138
|
-
}
|
|
139
|
-
return record;
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
export class DummyRedisRulesWriter implements AccessRulesWriter {
|
|
143
|
-
constructor(private readonly logger: Logger) {}
|
|
144
|
-
|
|
145
|
-
async insertRules(ruleEntries: AccessRuleEntry[]): Promise<string[]> {
|
|
146
|
-
this.logger.info(() => ({
|
|
147
|
-
msg: "Dummy insertRules() has no effect (redis is not ready)",
|
|
148
|
-
data: {
|
|
149
|
-
ruleEntries,
|
|
150
|
-
},
|
|
151
|
-
}));
|
|
152
|
-
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async deleteRules(ruleIds: string[]): Promise<void> {
|
|
157
|
-
this.logger.info(() => ({
|
|
158
|
-
msg: "Dummy deleteRules() has no effect (redis is not ready)",
|
|
159
|
-
data: {
|
|
160
|
-
ruleIds,
|
|
161
|
-
},
|
|
162
|
-
}));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async deleteAllRules(): Promise<number> {
|
|
166
|
-
this.logger.info(() => ({
|
|
167
|
-
msg: "Dummy deleteAllRules() has no effect (redis is not ready)",
|
|
168
|
-
}));
|
|
169
|
-
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
}
|
package/src/rule.ts
DELETED
|
@@ -1,122 +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
|
-
import type { CaptchaType, ResultReason } from "@prosopo/types";
|
|
15
|
-
|
|
16
|
-
export enum AccessPolicyType {
|
|
17
|
-
Block = "block",
|
|
18
|
-
Restrict = "restrict",
|
|
19
|
-
// Explicit allow-list: request that matches an Allow rule bypasses the
|
|
20
|
-
// challenge flow and gets an `authenticated` session (captchaType =
|
|
21
|
-
// authenticated on the frictionless response). Fires for any qualifying
|
|
22
|
-
// scope — verified Web Bot Auth agent, allow-listed IP, ja4/ja4_and_ip
|
|
23
|
-
// match, UA substring, ASN, country. IP-binding on the resulting
|
|
24
|
-
// session token defends against replay from a different IP. Opt-in per
|
|
25
|
-
// rule; the operator authors an Allow rule the same way they'd author
|
|
26
|
-
// a Block or Restrict rule.
|
|
27
|
-
Allow = "allow",
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Sentinel stamped on the Redis `clientId` field for rules that would
|
|
31
|
-
// otherwise store undefined (i.e. global rules). Lets the read path
|
|
32
|
-
// probe `@clientId:{global}` via the posting-list index instead of the
|
|
33
|
-
// expensive `ismissing(@clientId)` set-difference walk that degrades
|
|
34
|
-
// sharply once the global rule set grows into the 10k+ range. The
|
|
35
|
-
// reader converts it back to `undefined` at parse time so downstream
|
|
36
|
-
// code sees the original AccessRule shape. Kept in this types-only
|
|
37
|
-
// module so both the writer/index side and the parser/input side can
|
|
38
|
-
// import it without pulling in a circular dependency through
|
|
39
|
-
// transformRule.
|
|
40
|
-
export const GLOBAL_CLIENT_SCOPE_SENTINEL = "global";
|
|
41
|
-
|
|
42
|
-
export type AccessPolicy = {
|
|
43
|
-
type: AccessPolicyType;
|
|
44
|
-
captchaType?: CaptchaType;
|
|
45
|
-
description?: string;
|
|
46
|
-
solvedImagesCount?: number;
|
|
47
|
-
imageThreshold?: number;
|
|
48
|
-
powDifficulty?: number;
|
|
49
|
-
unsolvedImagesCount?: number;
|
|
50
|
-
frictionlessScore?: number;
|
|
51
|
-
// When true, a Block policy does NOT fire at the request-time
|
|
52
|
-
// blockMiddleware (so the user does not see a 401 on the captcha
|
|
53
|
-
// challenge endpoint) — it fires at the verify step instead, marking
|
|
54
|
-
// the commitment ACCESS_POLICY_BLOCK / disapproved. The verify
|
|
55
|
-
// response returns `{verified:false}` to the dApp's server while the
|
|
56
|
-
// user-facing widget completes normally. Mirrors the existing
|
|
57
|
-
// coords-rule deferral pattern: the middleware blanks coords out of
|
|
58
|
-
// the userScope, so coords rules can only ever be matched in the
|
|
59
|
-
// verify path; `deferToVerify` is the explicit form for non-coords
|
|
60
|
-
// signals (ja4, headersHash, etc.) when the operator wants the
|
|
61
|
-
// attacker to pay the captcha-solving cost before being rejected.
|
|
62
|
-
deferToVerify?: boolean;
|
|
63
|
-
// Replaces "Forbidden" in the 403 body of a matched Block policy.
|
|
64
|
-
messageKey?: ResultReason;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export type PolicyScope = {
|
|
68
|
-
clientId?: string;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export type UserIp = {
|
|
72
|
-
numericIp?: bigint;
|
|
73
|
-
numericIpMaskMin?: bigint;
|
|
74
|
-
numericIpMaskMax?: bigint;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export type UserAttributes = {
|
|
78
|
-
userId?: string;
|
|
79
|
-
ja4Hash?: string;
|
|
80
|
-
headersHash?: string;
|
|
81
|
-
userAgentHash?: string;
|
|
82
|
-
headHash?: string;
|
|
83
|
-
coords?: string;
|
|
84
|
-
countryCode?: string;
|
|
85
|
-
asn?: number;
|
|
86
|
-
// Operating system classified server-side from the request User-Agent (see
|
|
87
|
-
// `classifyOs`). Stored as a plain lowercase tag — one of `OsName` — and
|
|
88
|
-
// matched like `countryCode` (exact equality, not hashed). Lets a rule
|
|
89
|
-
// drop/limit requests from a given OS even when the client omits client
|
|
90
|
-
// hints.
|
|
91
|
-
os?: string;
|
|
92
|
-
browser?: string;
|
|
93
|
-
// Arbitrary-header matching (see `headerMatch.ts` and the portal Header
|
|
94
|
-
// Restriction card). A header rule carries `headerName` + `headerValue` +
|
|
95
|
-
// `headerOperator`; unlike the other dimensions these are checked in code
|
|
96
|
-
// against the raw request headers — Redis TAG can't express substring
|
|
97
|
-
// `contains` or per-rule operators — so they are not part of the Redis
|
|
98
|
-
// matching query. `headerMatch` is the indexed sentinel (always
|
|
99
|
-
// `HEADER_RULE_MARKER`) that makes such a rule a matching candidate for
|
|
100
|
-
// every request, so an allow-list rule still fires on a request that omits
|
|
101
|
-
// the target header.
|
|
102
|
-
headerMatch?: string;
|
|
103
|
-
headerName?: string;
|
|
104
|
-
headerValue?: string;
|
|
105
|
-
headerOperator?: string;
|
|
106
|
-
// Canonical Web Bot Auth Signature-Agent URL — e.g.
|
|
107
|
-
// "https://signatures.openai.com/". Matched at runtime against the
|
|
108
|
-
// `Signature-Agent` header after RFC 9421 Ed25519 verification succeeds.
|
|
109
|
-
// Stored as-is like `countryCode` (exact equality after normalisation).
|
|
110
|
-
// Rules with this field only match requests carrying a valid Web Bot
|
|
111
|
-
// Auth signature; unverified traffic falls through.
|
|
112
|
-
webBotAuthAgent?: string;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
export type UserScope = UserAttributes & UserIp;
|
|
116
|
-
|
|
117
|
-
// flat structure is used to fit the Redis requirements
|
|
118
|
-
export type AccessRule = AccessPolicy &
|
|
119
|
-
PolicyScope &
|
|
120
|
-
UserScope & {
|
|
121
|
-
groupId?: string;
|
|
122
|
-
};
|
package/src/ruleInput/.export.ts
DELETED
|
@@ -1,19 +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 { accessRuleInput, type AccessRulesFilterInput } from "./ruleInput.js";
|
|
16
|
-
|
|
17
|
-
export { accessPolicyInput, policyScopeInput } from "./policyInput.js";
|
|
18
|
-
|
|
19
|
-
export { userScopeInput } from "./userScopeInput.js";
|
|
@@ -1,120 +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 } from "@prosopo/common";
|
|
16
|
-
import { CaptchaTypeSchema, ResultReason } from "@prosopo/types";
|
|
17
|
-
import { z } from "zod";
|
|
18
|
-
import {
|
|
19
|
-
type AccessPolicy,
|
|
20
|
-
AccessPolicyType,
|
|
21
|
-
GLOBAL_CLIENT_SCOPE_SENTINEL,
|
|
22
|
-
type PolicyScope,
|
|
23
|
-
} from "#policy/rule.js";
|
|
24
|
-
|
|
25
|
-
// `satisfies ZodType<AccessPolicy>` is intentionally omitted: the
|
|
26
|
-
// `deferToVerify` preprocess widens the schema's input type to `unknown`
|
|
27
|
-
// (preprocess accepts anything), which fails the
|
|
28
|
-
// `ZodType<T, ZodTypeDef, T>` identity check. The `AllKeys<AccessPolicy>`
|
|
29
|
-
// constraint still catches missing-field regressions.
|
|
30
|
-
//
|
|
31
|
-
// The `superRefine` rejects Block+captchaType and Block+solvedImagesCount
|
|
32
|
-
// at write time. Those fields are meaningless on a Block policy — the
|
|
33
|
-
// sanitizer used to silently strip them, which meant operators writing
|
|
34
|
-
// e.g. "block image captchas for this IP" got a rule that actually
|
|
35
|
-
// blocked EVERY captcha type. The bug in #2885 (Block+deferToVerify 400
|
|
36
|
-
// on every /captcha/*) was a knock-on of the same silent-strip: the
|
|
37
|
-
// captchaType-equality check on the read side compared `undefined` to the
|
|
38
|
-
// request's captchaType and rejected. Rejecting at input surfaces the
|
|
39
|
-
// mismatch loudly so operators can adjust their intent (they usually
|
|
40
|
-
// wanted a Restrict) rather than persisting a rule that doesn't match
|
|
41
|
-
// what they typed.
|
|
42
|
-
// Base ZodObject shape — reused via `.shape` by `ruleInput` and
|
|
43
|
-
// `transformRule` (both compose accessPolicyInput's fields with policy /
|
|
44
|
-
// user scope fields via object spread). Split from `accessPolicyInput`
|
|
45
|
-
// below because that one applies a `.superRefine` and returns a
|
|
46
|
-
// `ZodEffects`, which loses `.shape`.
|
|
47
|
-
export const accessPolicyInputShape = z.object({
|
|
48
|
-
type: z.nativeEnum(AccessPolicyType),
|
|
49
|
-
captchaType: CaptchaTypeSchema.optional(),
|
|
50
|
-
description: z.coerce.string().optional(),
|
|
51
|
-
// Redis stores values as strings, so coerce is needed to parse properly
|
|
52
|
-
solvedImagesCount: z.coerce.number().optional(),
|
|
53
|
-
// the percentage of image panels that must be solved per image CAPTCHA
|
|
54
|
-
imageThreshold: z.coerce.number().optional(),
|
|
55
|
-
// the Proof-of-Work difficulty level
|
|
56
|
-
powDifficulty: z.coerce.number().optional(),
|
|
57
|
-
// the number of unsolved image CAPTCHA challenges to serve
|
|
58
|
-
unsolvedImagesCount: z.coerce.number().optional(),
|
|
59
|
-
// used to increase the user's score
|
|
60
|
-
frictionlessScore: z.coerce.number().optional(),
|
|
61
|
-
// Skip the request-time block middleware and only fire at verify.
|
|
62
|
-
// Redis stores booleans as strings — preprocess so "true"/"false"
|
|
63
|
-
// round-trip to the JS boolean the matcher expects.
|
|
64
|
-
deferToVerify: z
|
|
65
|
-
.preprocess((v) => (typeof v === "string" ? v === "true" : v), z.boolean())
|
|
66
|
-
.optional(),
|
|
67
|
-
messageKey: z.nativeEnum(ResultReason).optional(),
|
|
68
|
-
} satisfies AllKeys<AccessPolicy>);
|
|
69
|
-
|
|
70
|
-
export const accessPolicyInput = accessPolicyInputShape.superRefine(
|
|
71
|
-
(policy, ctx) => {
|
|
72
|
-
if (policy.type !== AccessPolicyType.Block) return;
|
|
73
|
-
if (policy.captchaType !== undefined) {
|
|
74
|
-
ctx.addIssue({
|
|
75
|
-
code: z.ZodIssueCode.custom,
|
|
76
|
-
path: ["captchaType"],
|
|
77
|
-
message:
|
|
78
|
-
"Block policies cannot pin a captchaType — Block always applies to every captcha type. Use a Restrict policy if you want to narrow the effect to one captcha type.",
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
if (policy.solvedImagesCount !== undefined) {
|
|
82
|
-
ctx.addIssue({
|
|
83
|
-
code: z.ZodIssueCode.custom,
|
|
84
|
-
path: ["solvedImagesCount"],
|
|
85
|
-
message:
|
|
86
|
-
"Block policies cannot set solvedImagesCount — the field only applies to Restrict policies that adjust image-captcha rounds.",
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
// Historical helper: strip fields the sanitizer used to remove silently.
|
|
93
|
-
// Kept as a no-op-in-practice safety net because Redis records written
|
|
94
|
-
// before the superRefine on `accessPolicyInput` (above) may still carry
|
|
95
|
-
// the stripped fields — reading them back should still normalize to the
|
|
96
|
-
// canonical shape. New writes are rejected at input.
|
|
97
|
-
export const sanitizeAccessPolicy = (policy: AccessPolicy): AccessPolicy => {
|
|
98
|
-
if (policy.type === AccessPolicyType.Block) {
|
|
99
|
-
const { captchaType, solvedImagesCount, ...blockPolicy } = policy;
|
|
100
|
-
return blockPolicy;
|
|
101
|
-
}
|
|
102
|
-
return policy;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// `satisfies ZodType<PolicyScope>` is intentionally omitted (matches
|
|
106
|
-
// accessPolicyInput above): the `preprocess` wrapper widens the schema's
|
|
107
|
-
// input type to `unknown`, which fails the `ZodType<T, ZodTypeDef, T>`
|
|
108
|
-
// identity check. The `AllKeys<PolicyScope>` constraint still catches
|
|
109
|
-
// missing-field regressions.
|
|
110
|
-
export const policyScopeInput = z.object({
|
|
111
|
-
// `getRedisRuleValue` stamps a sentinel string on global rules so the
|
|
112
|
-
// read-time query can probe `@clientId:{global}` cheaply. Undo the
|
|
113
|
-
// stamp here so consumers (rankCandidateRules, response payloads,
|
|
114
|
-
// tests) continue to see `undefined` for global rules — the mongoose
|
|
115
|
-
// side and the API input side never emit the sentinel.
|
|
116
|
-
clientId: z.preprocess(
|
|
117
|
-
(v) => (v === GLOBAL_CLIENT_SCOPE_SENTINEL ? undefined : v),
|
|
118
|
-
z.coerce.string().optional(),
|
|
119
|
-
),
|
|
120
|
-
} satisfies AllKeys<PolicyScope>);
|
|
@@ -1,123 +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 } from "@prosopo/common";
|
|
16
|
-
import { type ZodType, z } from "zod";
|
|
17
|
-
import type { AccessPolicy, AccessRule, PolicyScope } from "#policy/rule.js";
|
|
18
|
-
import {
|
|
19
|
-
type AccessRuleEntry,
|
|
20
|
-
type AccessRulesFilter,
|
|
21
|
-
FilterScopeMatch,
|
|
22
|
-
} from "#policy/rulesStorage.js";
|
|
23
|
-
import { accessPolicyInputShape, policyScopeInput } from "./policyInput.js";
|
|
24
|
-
import { type UserScopeInput, userScopeInput } from "./userScopeInput.js";
|
|
25
|
-
|
|
26
|
-
type RuleGroupInput = {
|
|
27
|
-
groupId?: string;
|
|
28
|
-
ruleGroupId?: string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type AccessRuleInput = AccessPolicy &
|
|
32
|
-
PolicyScope &
|
|
33
|
-
UserScopeInput &
|
|
34
|
-
RuleGroupInput;
|
|
35
|
-
|
|
36
|
-
const ruleGroupInput = z
|
|
37
|
-
.object({
|
|
38
|
-
groupId: z.coerce.string().optional(),
|
|
39
|
-
ruleGroupId: z.coerce.string().optional(),
|
|
40
|
-
} satisfies AllKeys<RuleGroupInput>)
|
|
41
|
-
.transform((ruleGroupInput: RuleGroupInput) => {
|
|
42
|
-
const { ruleGroupId, ...ruleGroup } = ruleGroupInput;
|
|
43
|
-
|
|
44
|
-
if ("string" === typeof ruleGroupId) {
|
|
45
|
-
ruleGroup.groupId = ruleGroupId;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return ruleGroup;
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
// Explicit `ZodType<…, ZodTypeDef, unknown>` annotation rather than the
|
|
52
|
-
// strict-identity form because `accessPolicyInputShape.shape.deferToVerify`
|
|
53
|
-
// uses `z.preprocess` which widens the input position to `unknown`. The
|
|
54
|
-
// relaxed annotation is portable for declaration emit; the `transform`
|
|
55
|
-
// pins the OUTPUT to AccessRule.
|
|
56
|
-
//
|
|
57
|
-
// Uses the unrefined `accessPolicyInputShape` (not `accessPolicyInput`)
|
|
58
|
-
// because the API-write refinement rejecting Block+captchaType is a
|
|
59
|
-
// write-time input guard — the READ path (Redis reader → accessRuleInput
|
|
60
|
-
// parse) must still accept the legacy shapes for records written before
|
|
61
|
-
// the refinement landed. Otherwise the reader would throw on every
|
|
62
|
-
// pre-existing Block rule that carried a stripped captchaType field.
|
|
63
|
-
export const accessRuleInput: ZodType<AccessRule, z.ZodTypeDef, unknown> = z
|
|
64
|
-
.object({
|
|
65
|
-
...accessPolicyInputShape.shape,
|
|
66
|
-
...policyScopeInput.shape,
|
|
67
|
-
})
|
|
68
|
-
.and(userScopeInput)
|
|
69
|
-
.and(ruleGroupInput)
|
|
70
|
-
.transform((ruleInput: AccessRuleInput): AccessRule => ruleInput);
|
|
71
|
-
|
|
72
|
-
export const ruleEntryInput: ZodType<AccessRuleEntry, z.ZodTypeDef, unknown> =
|
|
73
|
-
z.object({
|
|
74
|
-
rule: accessRuleInput,
|
|
75
|
-
expiresUnixTimestamp: z.coerce.number().optional(),
|
|
76
|
-
} satisfies AllKeys<AccessRuleEntry>);
|
|
77
|
-
|
|
78
|
-
export type AccessRulesFilterInput = AccessRulesFilter & {
|
|
79
|
-
userScope?: UserScopeInput;
|
|
80
|
-
policyScopes?: PolicyScope[];
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// `satisfies ZodType<AccessRulesFilterInput>` is intentionally omitted:
|
|
84
|
-
// `policyScopeInput.clientId` uses `z.preprocess` to unwrap the Redis
|
|
85
|
-
// `global` sentinel, which widens the input type to `unknown`. The
|
|
86
|
-
// output type is still `AccessRulesFilterInput` (Zod's `_output`); the
|
|
87
|
-
// downstream `DeleteRulesSchema` / `FindRulesSchema` use the relaxed
|
|
88
|
-
// `ZodType<T, ZodTypeDef, unknown>` form for the same reason.
|
|
89
|
-
export const accessRulesFilterInput = z.object({
|
|
90
|
-
policyScope: policyScopeInput.optional(),
|
|
91
|
-
policyScopes: z.array(policyScopeInput).optional(),
|
|
92
|
-
policyScopeMatch: z
|
|
93
|
-
.nativeEnum(FilterScopeMatch)
|
|
94
|
-
.default(FilterScopeMatch.Exact),
|
|
95
|
-
userScope: userScopeInput.optional(),
|
|
96
|
-
userScopeMatch: z
|
|
97
|
-
.nativeEnum(FilterScopeMatch)
|
|
98
|
-
.default(FilterScopeMatch.Exact),
|
|
99
|
-
groupId: z.string().optional(),
|
|
100
|
-
blockOnly: z.boolean().optional(),
|
|
101
|
-
includeDeferred: z.boolean().optional(),
|
|
102
|
-
} satisfies AllKeys<AccessRulesFilterInput>);
|
|
103
|
-
|
|
104
|
-
export const getAccessRuleFiltersFromInput = (
|
|
105
|
-
filterInput: AccessRulesFilterInput,
|
|
106
|
-
): AccessRulesFilter[] => {
|
|
107
|
-
const { policyScopes, policyScope, ...filterBase } = filterInput;
|
|
108
|
-
|
|
109
|
-
const allPolicyScopes = policyScopes || [];
|
|
110
|
-
|
|
111
|
-
if (policyScope) {
|
|
112
|
-
allPolicyScopes.push(policyScope);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (allPolicyScopes.length > 0) {
|
|
116
|
-
return allPolicyScopes.map((policyScope) => ({
|
|
117
|
-
...filterBase,
|
|
118
|
-
policyScope,
|
|
119
|
-
}));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return [filterBase];
|
|
123
|
-
};
|