@prosopo/user-access-policy 3.5.19 → 3.5.28
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/CHANGELOG.md +84 -0
- package/dist/.export.js +21 -0
- package/dist/api/.export.js +11 -0
- package/dist/api/delete/.export.js +1 -0
- package/dist/api/{deleteAllRulesEndpoint.js → delete/deleteAllRules.js} +10 -9
- package/dist/api/delete/deleteRuleGroups.js +52 -0
- package/dist/api/delete/deleteRules.js +43 -0
- package/dist/api/read/.export.js +1 -0
- package/dist/api/read/fetchRules.js +43 -0
- package/dist/api/read/findRuleIds.js +50 -0
- package/dist/api/read/getMissingIds.js +41 -0
- package/dist/api/ruleApiRoutes.js +131 -0
- package/dist/api/rulesApiClient.js +93 -0
- package/dist/api/write/.export.js +1 -0
- package/dist/api/write/insertRules.js +102 -0
- package/dist/api/write/rehashRules.js +57 -0
- package/dist/cjs/.export.cjs +21 -0
- package/dist/cjs/api/.export.cjs +11 -0
- package/dist/cjs/api/delete/.export.cjs +1 -0
- package/dist/cjs/api/{deleteAllRulesEndpoint.cjs → delete/deleteAllRules.cjs} +9 -8
- package/dist/cjs/api/delete/deleteRuleGroups.cjs +52 -0
- package/dist/cjs/api/delete/deleteRules.cjs +43 -0
- package/dist/cjs/api/read/.export.cjs +1 -0
- package/dist/cjs/api/read/fetchRules.cjs +43 -0
- package/dist/cjs/api/read/findRuleIds.cjs +50 -0
- package/dist/cjs/api/read/getMissingIds.cjs +41 -0
- package/dist/cjs/api/ruleApiRoutes.cjs +131 -0
- package/dist/cjs/api/rulesApiClient.cjs +93 -0
- package/dist/cjs/api/write/.export.cjs +1 -0
- package/dist/cjs/api/write/insertRules.cjs +102 -0
- package/dist/cjs/api/write/rehashRules.cjs +57 -0
- package/dist/cjs/mongoose/.export.cjs +4 -0
- package/dist/cjs/mongoose/mongooseRuleSchema.cjs +36 -0
- package/dist/cjs/redis/.export.cjs +6 -0
- package/dist/cjs/redis/reader/redisAggregate.cjs +60 -0
- package/dist/cjs/redis/reader/redisRulesQuery.cjs +99 -0
- package/dist/cjs/redis/reader/redisRulesReader.cjs +230 -0
- package/dist/cjs/redis/redisClient.cjs +67 -0
- package/dist/cjs/redis/redisRuleIndex.cjs +50 -0
- package/dist/cjs/redis/redisRulesStorage.cjs +22 -9
- package/dist/cjs/redis/redisRulesWriter.cjs +91 -64
- package/dist/cjs/rule.cjs +8 -0
- package/dist/cjs/ruleInput/.export.cjs +9 -0
- package/dist/cjs/ruleInput/policyInput.cjs +25 -0
- package/dist/cjs/ruleInput/ruleInput.cjs +50 -0
- package/dist/cjs/ruleInput/userScopeInput.cjs +55 -0
- package/dist/cjs/ruleRecord.cjs +23 -0
- package/dist/cjs/rulesStorage.cjs +8 -0
- package/dist/cjs/transformRule.cjs +77 -0
- package/dist/mongoose/.export.js +4 -0
- package/dist/mongoose/mongooseRuleSchema.js +36 -0
- package/dist/redis/.export.js +6 -0
- package/dist/redis/reader/redisAggregate.js +60 -0
- package/dist/redis/reader/redisRulesQuery.js +99 -0
- package/dist/redis/reader/redisRulesReader.js +213 -0
- package/dist/redis/redisClient.js +67 -0
- package/dist/redis/redisRuleIndex.js +50 -0
- package/dist/redis/redisRulesStorage.js +23 -10
- package/dist/redis/redisRulesWriter.js +91 -64
- package/dist/rule.js +8 -0
- package/dist/ruleInput/.export.js +9 -0
- package/dist/ruleInput/policyInput.js +25 -0
- package/dist/ruleInput/ruleInput.js +50 -0
- package/dist/ruleInput/userScopeInput.js +55 -0
- package/dist/ruleRecord.js +23 -0
- package/dist/rulesStorage.js +8 -0
- package/dist/transformRule.js +77 -0
- package/entries.ts +20 -0
- package/package.json +34 -18
- package/vite.cjs.config.ts +4 -1
- package/vite.esm.config.ts +6 -1
- package/dist/accessPolicy.js +0 -80
- package/dist/accessPolicyResolver.js +0 -31
- package/dist/accessRules.js +0 -11
- package/dist/api/accessRuleApiRoutes.js +0 -79
- package/dist/api/accessRulesApiClient.js +0 -38
- package/dist/api/deleteRulesEndpoint.js +0 -34
- package/dist/api/insertRulesEndpoint.js +0 -62
- package/dist/cjs/accessPolicy.cjs +0 -80
- package/dist/cjs/accessPolicyResolver.cjs +0 -31
- package/dist/cjs/accessRules.cjs +0 -11
- package/dist/cjs/api/accessRuleApiRoutes.cjs +0 -79
- package/dist/cjs/api/accessRulesApiClient.cjs +0 -38
- package/dist/cjs/api/deleteRulesEndpoint.cjs +0 -34
- package/dist/cjs/api/insertRulesEndpoint.cjs +0 -62
- package/dist/cjs/index.cjs +0 -31
- package/dist/cjs/redis/redisRulesIndex.cjs +0 -138
- package/dist/cjs/redis/redisRulesReader.cjs +0 -142
- package/dist/cjs/util.cjs +0 -5
- package/dist/index.js +0 -32
- package/dist/redis/redisRulesIndex.js +0 -138
- package/dist/redis/redisRulesReader.js +0 -125
- package/dist/util.js +0 -5
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { userScopeSchema } from "../../ruleInput/userScopeInput.js";
|
|
2
|
+
import { FilterScopeMatch } from "../../rulesStorage.js";
|
|
3
|
+
const REDIS_QUERY_DIALECT = 2;
|
|
4
|
+
const userIpQueries = {
|
|
5
|
+
numericIp: (value, scope) => {
|
|
6
|
+
if (void 0 !== value) {
|
|
7
|
+
return `( @numericIp:[${value} ${value}] | ( @numericIpMaskMin:[-inf ${value}] @numericIpMaskMax:[${value} +inf] ) )`;
|
|
8
|
+
}
|
|
9
|
+
if (scope.numericIpMaskMin === void 0 && scope.numericIpMaskMax === void 0) {
|
|
10
|
+
return "ismissing(@numericIp) ismissing(@numericIpMaskMin) ismissing(@numericIpMaskMax)";
|
|
11
|
+
}
|
|
12
|
+
return "";
|
|
13
|
+
},
|
|
14
|
+
numericIpMaskMin: (value, scope) => {
|
|
15
|
+
if (scope.numericIp !== void 0) {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
return value !== void 0 ? `@numericIpMaskMin:[-inf ${value}]` : "ismissing(@numericIpMaskMin)";
|
|
19
|
+
},
|
|
20
|
+
numericIpMaskMax: (value, scope) => {
|
|
21
|
+
if (scope.numericIp !== void 0) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
return value !== void 0 ? `@numericIpMaskMax:[${value} +inf]` : "ismissing(@numericIpMaskMax)";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const getUserScopeQuery = (userScope, FilterScopeMatchType, matchingFieldsOnly) => {
|
|
28
|
+
let scopeEntries = Object.entries(userScope);
|
|
29
|
+
let scopeJoinType = " ";
|
|
30
|
+
if (FilterScopeMatchType === FilterScopeMatch.Greedy) {
|
|
31
|
+
scopeEntries = scopeEntries.filter(
|
|
32
|
+
([_, value]) => value !== void 0
|
|
33
|
+
);
|
|
34
|
+
scopeJoinType = " | ";
|
|
35
|
+
}
|
|
36
|
+
if (matchingFieldsOnly) {
|
|
37
|
+
const scopeMap = new Map(scopeEntries);
|
|
38
|
+
if (scopeMap.has("numericIp") && scopeMap.get("numericIp") === void 0) {
|
|
39
|
+
scopeMap.set("numericIpMaskMin", void 0);
|
|
40
|
+
scopeMap.set("numericIpMaskMax", void 0);
|
|
41
|
+
}
|
|
42
|
+
for (const name of Object.keys(userScopeSchema.shape)) {
|
|
43
|
+
if (!scopeMap.has(name)) {
|
|
44
|
+
scopeMap.set(name, void 0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
scopeEntries = [...scopeMap.entries()];
|
|
48
|
+
}
|
|
49
|
+
const scopeObj = Object.fromEntries(scopeEntries);
|
|
50
|
+
return scopeEntries.map(
|
|
51
|
+
([scopeFieldName, scopeFieldValue]) => getUserScopeFieldQuery(
|
|
52
|
+
scopeFieldName,
|
|
53
|
+
scopeFieldValue,
|
|
54
|
+
FilterScopeMatchType,
|
|
55
|
+
scopeObj
|
|
56
|
+
)
|
|
57
|
+
).filter(Boolean).join(scopeJoinType);
|
|
58
|
+
};
|
|
59
|
+
const getUserScopeFieldQuery = (fieldName, fieldValue, scopeMatch, fullScope) => {
|
|
60
|
+
if (fieldName in userIpQueries) {
|
|
61
|
+
const queryBuilder = userIpQueries[fieldName];
|
|
62
|
+
return queryBuilder(fieldValue, fullScope);
|
|
63
|
+
}
|
|
64
|
+
return void 0 === fieldValue ? `ismissing(@${fieldName})` : `@${fieldName}:{${fieldValue}}`;
|
|
65
|
+
};
|
|
66
|
+
const getPolicyScopeQuery = (policyScope, scopeMatch) => {
|
|
67
|
+
const clientId = policyScope?.clientId;
|
|
68
|
+
if ("string" === typeof clientId) {
|
|
69
|
+
return FilterScopeMatch.Exact === scopeMatch ? `@clientId:{${clientId}}` : `( @clientId:{${clientId}} | ismissing(@clientId) )`;
|
|
70
|
+
}
|
|
71
|
+
return FilterScopeMatch.Exact === scopeMatch ? "ismissing(@clientId)" : "";
|
|
72
|
+
};
|
|
73
|
+
const getRulesRedisQuery = (filter, matchingFieldsOnly) => {
|
|
74
|
+
const { policyScope, userScope } = filter;
|
|
75
|
+
const queryParts = [];
|
|
76
|
+
if (filter.groupId) {
|
|
77
|
+
queryParts.push(`@groupId:{${filter.groupId}}`);
|
|
78
|
+
}
|
|
79
|
+
const policyScopeQuery = getPolicyScopeQuery(
|
|
80
|
+
policyScope,
|
|
81
|
+
filter.policyScopeMatch
|
|
82
|
+
);
|
|
83
|
+
if (policyScopeQuery) {
|
|
84
|
+
queryParts.push(policyScopeQuery);
|
|
85
|
+
}
|
|
86
|
+
if (userScope && Object.keys(userScope).length > 0) {
|
|
87
|
+
const userScopeFilter = getUserScopeQuery(
|
|
88
|
+
userScope,
|
|
89
|
+
filter.userScopeMatch,
|
|
90
|
+
matchingFieldsOnly
|
|
91
|
+
);
|
|
92
|
+
queryParts.push(`( ${userScopeFilter} )`);
|
|
93
|
+
}
|
|
94
|
+
return queryParts.length > 0 ? queryParts.join(" ") : "*";
|
|
95
|
+
};
|
|
96
|
+
export {
|
|
97
|
+
REDIS_QUERY_DIALECT,
|
|
98
|
+
getRulesRedisQuery
|
|
99
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as util from "node:util";
|
|
2
|
+
import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
|
|
3
|
+
import { getRulesRedisQuery, REDIS_QUERY_DIALECT } from "./redisRulesQuery.js";
|
|
4
|
+
import { REDIS_BATCH_SIZE, getMissingRedisKeys, parseRedisRecords, fetchRedisHashRecords } from "../redisClient.js";
|
|
5
|
+
import { ACCESS_RULE_REDIS_KEY_PREFIX, ACCESS_RULES_REDIS_INDEX_NAME } from "../redisRuleIndex.js";
|
|
6
|
+
import { accessRuleInput } from "../../ruleInput/ruleInput.js";
|
|
7
|
+
import { aggregateRedisKeys } from "./redisAggregate.js";
|
|
8
|
+
class RedisRulesReader {
|
|
9
|
+
constructor(client, logger) {
|
|
10
|
+
this.client = client;
|
|
11
|
+
this.logger = logger;
|
|
12
|
+
}
|
|
13
|
+
async getMissingRuleIds(ruleIds) {
|
|
14
|
+
const ruleKeys = this.getRuleKeys(ruleIds);
|
|
15
|
+
const keyBatches = chunkIntoBatches(ruleKeys, REDIS_BATCH_SIZE);
|
|
16
|
+
const missingKeyBatches = await executeBatchesSequentially(
|
|
17
|
+
keyBatches,
|
|
18
|
+
async (keysBatch) => getMissingRedisKeys(this.client, keysBatch)
|
|
19
|
+
);
|
|
20
|
+
return missingKeyBatches.flat().map((ruleKey) => ruleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length));
|
|
21
|
+
}
|
|
22
|
+
async fetchRules(ruleIds) {
|
|
23
|
+
const ruleKeys = this.getRuleKeys(ruleIds);
|
|
24
|
+
const keyBatches = chunkIntoBatches(ruleKeys, REDIS_BATCH_SIZE);
|
|
25
|
+
const entryBatches = await executeBatchesSequentially(
|
|
26
|
+
keyBatches,
|
|
27
|
+
(keysBatch) => this.fetchRuleEntries(keysBatch)
|
|
28
|
+
);
|
|
29
|
+
return entryBatches.flat();
|
|
30
|
+
}
|
|
31
|
+
async findRules(filter, matchingFieldsOnly = false, skipEmptyUserScopes = true) {
|
|
32
|
+
const query = getRulesRedisQuery(filter, matchingFieldsOnly);
|
|
33
|
+
if (skipEmptyUserScopes && query === "ismissing(@clientId)") {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
let searchReply;
|
|
37
|
+
try {
|
|
38
|
+
searchReply = await this.client.ft.search(
|
|
39
|
+
ACCESS_RULES_REDIS_INDEX_NAME,
|
|
40
|
+
query,
|
|
41
|
+
{
|
|
42
|
+
DIALECT: REDIS_QUERY_DIALECT,
|
|
43
|
+
// FT.search doesn't support "unlimited" selects
|
|
44
|
+
LIMIT: {
|
|
45
|
+
from: 0,
|
|
46
|
+
size: REDIS_BATCH_SIZE
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
if (searchReply.total > 0) {
|
|
51
|
+
this.logger.debug(() => ({
|
|
52
|
+
msg: "Executed search query",
|
|
53
|
+
data: {
|
|
54
|
+
inspect: util.inspect(
|
|
55
|
+
{
|
|
56
|
+
filter,
|
|
57
|
+
searchReply,
|
|
58
|
+
query
|
|
59
|
+
},
|
|
60
|
+
{ depth: null }
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
this.logger.error(() => ({
|
|
67
|
+
err: e,
|
|
68
|
+
data: {
|
|
69
|
+
inspect: util.inspect(
|
|
70
|
+
{
|
|
71
|
+
query,
|
|
72
|
+
filter
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
depth: null
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
msg: "failed to execute search query"
|
|
80
|
+
}));
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const records = searchReply.documents.map(({ value }) => value);
|
|
84
|
+
return parseRedisRecords(records, accessRuleInput, this.logger);
|
|
85
|
+
}
|
|
86
|
+
async findRuleIds(filter, matchingFieldsOnly = false) {
|
|
87
|
+
const query = getRulesRedisQuery(filter, matchingFieldsOnly);
|
|
88
|
+
let ruleIds = [];
|
|
89
|
+
try {
|
|
90
|
+
const ruleKeys = await aggregateRedisKeys(
|
|
91
|
+
this.client,
|
|
92
|
+
query,
|
|
93
|
+
this.logger
|
|
94
|
+
);
|
|
95
|
+
ruleIds = ruleKeys.map(
|
|
96
|
+
(ruleKey) => ruleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length)
|
|
97
|
+
);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
this.logger.error(() => ({
|
|
100
|
+
err: e,
|
|
101
|
+
data: {
|
|
102
|
+
inspect: util.inspect(
|
|
103
|
+
{
|
|
104
|
+
query,
|
|
105
|
+
filter
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
depth: null
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
},
|
|
112
|
+
msg: "Failed to execute search query for rule IDs"
|
|
113
|
+
}));
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
this.logger.debug(() => ({
|
|
117
|
+
msg: "Executed search query for rule IDs",
|
|
118
|
+
data: {
|
|
119
|
+
query,
|
|
120
|
+
foundCount: ruleIds.length,
|
|
121
|
+
foundIds: ruleIds
|
|
122
|
+
}
|
|
123
|
+
}));
|
|
124
|
+
return ruleIds;
|
|
125
|
+
}
|
|
126
|
+
async fetchAllRuleIds(batchHandler) {
|
|
127
|
+
const keysBatchHandler = async (keys) => {
|
|
128
|
+
const ids = keys.map(
|
|
129
|
+
(ruleKey) => ruleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length)
|
|
130
|
+
);
|
|
131
|
+
await batchHandler(ids);
|
|
132
|
+
};
|
|
133
|
+
await aggregateRedisKeys(this.client, "*", this.logger, keysBatchHandler);
|
|
134
|
+
}
|
|
135
|
+
async fetchRuleEntries(keys) {
|
|
136
|
+
const { records, expirations } = await fetchRedisHashRecords(
|
|
137
|
+
this.client,
|
|
138
|
+
keys,
|
|
139
|
+
this.logger
|
|
140
|
+
);
|
|
141
|
+
const entries = [];
|
|
142
|
+
for (const [index, ruleData] of records.entries()) {
|
|
143
|
+
const isRulePresent = Object.keys(ruleData).length > 0;
|
|
144
|
+
if (isRulePresent) {
|
|
145
|
+
const rule = parseRedisRecords(
|
|
146
|
+
[ruleData],
|
|
147
|
+
accessRuleInput,
|
|
148
|
+
this.logger
|
|
149
|
+
)[0];
|
|
150
|
+
if (rule) {
|
|
151
|
+
entries.push({
|
|
152
|
+
rule,
|
|
153
|
+
expiresUnixTimestamp: expirations[index]
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return entries;
|
|
159
|
+
}
|
|
160
|
+
getRuleKeys(ruleIds) {
|
|
161
|
+
return ruleIds.map((id) => `${ACCESS_RULE_REDIS_KEY_PREFIX}${id}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
class DummyRedisRulesReader {
|
|
165
|
+
constructor(logger) {
|
|
166
|
+
this.logger = logger;
|
|
167
|
+
}
|
|
168
|
+
async getMissingRuleIds(ruleIds) {
|
|
169
|
+
this.logger.info(() => ({
|
|
170
|
+
msg: "Dummy getMissingRuleIds() has no effect (redis is not ready)",
|
|
171
|
+
data: {
|
|
172
|
+
ruleIds
|
|
173
|
+
}
|
|
174
|
+
}));
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
async fetchRules(ruleIds) {
|
|
178
|
+
this.logger.info(() => ({
|
|
179
|
+
msg: "Dummy fetchRule() has no effect (redis is not ready)",
|
|
180
|
+
data: {
|
|
181
|
+
ruleIds
|
|
182
|
+
}
|
|
183
|
+
}));
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
async findRules(filter, matchingFieldsOnly = false, skipEmptyUserScopes = true) {
|
|
187
|
+
this.logger.info(() => ({
|
|
188
|
+
msg: "Dummy findRules() has no effect (redis is not ready)",
|
|
189
|
+
data: {
|
|
190
|
+
filter
|
|
191
|
+
}
|
|
192
|
+
}));
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
async findRuleIds(filter, matchingFieldsOnly = false) {
|
|
196
|
+
this.logger.info(() => ({
|
|
197
|
+
msg: "Dummy findRuleIds() has no effect (redis is not ready)",
|
|
198
|
+
data: {
|
|
199
|
+
filter
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
async fetchAllRuleIds(batchHandler) {
|
|
205
|
+
this.logger.info(() => ({
|
|
206
|
+
msg: "Dummy fetchAllRuleIds() has no effect (redis is not ready)"
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export {
|
|
211
|
+
DummyRedisRulesReader,
|
|
212
|
+
RedisRulesReader
|
|
213
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const REDIS_BATCH_SIZE = 1e3;
|
|
3
|
+
const getMissingRedisKeys = async (client, keys) => {
|
|
4
|
+
const queries = client.multi();
|
|
5
|
+
keys.map((key) => {
|
|
6
|
+
queries.exists(key);
|
|
7
|
+
});
|
|
8
|
+
const records = await queries.exec();
|
|
9
|
+
const missingKeys = [];
|
|
10
|
+
records.map((exists, recordIndex) => {
|
|
11
|
+
if ("0" === String(exists)) {
|
|
12
|
+
const key = keys[recordIndex];
|
|
13
|
+
if (key) {
|
|
14
|
+
missingKeys.push(key);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
return missingKeys;
|
|
19
|
+
};
|
|
20
|
+
const fetchRedisHashRecords = async (client, keys, logger) => {
|
|
21
|
+
const rulesPipe = client.multi();
|
|
22
|
+
const expirationPipe = client.multi();
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
rulesPipe.hGetAll(key);
|
|
25
|
+
expirationPipe.expireTime(key);
|
|
26
|
+
}
|
|
27
|
+
const records = await rulesPipe.exec();
|
|
28
|
+
const expirationRecords = await expirationPipe.exec();
|
|
29
|
+
return {
|
|
30
|
+
records,
|
|
31
|
+
expirations: parseExpirationRecords(expirationRecords, logger)
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const parseRedisRecords = (records, recordSchema, logger) => records.flatMap((record) => {
|
|
35
|
+
const parseResult = recordSchema.safeParse(record);
|
|
36
|
+
if (parseResult.success) {
|
|
37
|
+
return [parseResult.data];
|
|
38
|
+
}
|
|
39
|
+
logger.error(() => ({
|
|
40
|
+
msg: "Failed to parse Redis record",
|
|
41
|
+
data: { record, error: parseResult.error }
|
|
42
|
+
}));
|
|
43
|
+
return [];
|
|
44
|
+
});
|
|
45
|
+
const expirationRecordSchema = z.coerce.number();
|
|
46
|
+
const UNSET_EXPIRATION_VALUE = -1;
|
|
47
|
+
const parseExpirationRecords = (records, logger) => records.flatMap((record) => {
|
|
48
|
+
const parseResult = expirationRecordSchema.safeParse(record);
|
|
49
|
+
if (parseResult.success) {
|
|
50
|
+
const expiration = UNSET_EXPIRATION_VALUE === parseResult.data ? void 0 : parseResult.data;
|
|
51
|
+
return [expiration];
|
|
52
|
+
}
|
|
53
|
+
logger.error(() => ({
|
|
54
|
+
msg: "Failed to parse Redis expiration record",
|
|
55
|
+
data: {
|
|
56
|
+
record,
|
|
57
|
+
error: parseResult.error
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
return [void 0];
|
|
61
|
+
});
|
|
62
|
+
export {
|
|
63
|
+
REDIS_BATCH_SIZE,
|
|
64
|
+
fetchRedisHashRecords,
|
|
65
|
+
getMissingRedisKeys,
|
|
66
|
+
parseRedisRecords
|
|
67
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SCHEMA_FIELD_TYPE } from "@redis/search";
|
|
2
|
+
import { makeAccessRuleHash } from "../transformRule.js";
|
|
3
|
+
const userIpRedisSchema = {
|
|
4
|
+
numericIpMaskMin: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
5
|
+
numericIpMaskMax: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true },
|
|
6
|
+
numericIp: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true }
|
|
7
|
+
};
|
|
8
|
+
const userAttributesRedisSchema = {
|
|
9
|
+
userId: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
10
|
+
ja4Hash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
11
|
+
headersHash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true },
|
|
12
|
+
userAgentHash: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true }
|
|
13
|
+
};
|
|
14
|
+
const userScopeRedisSchema = {
|
|
15
|
+
...userAttributesRedisSchema,
|
|
16
|
+
...userIpRedisSchema
|
|
17
|
+
};
|
|
18
|
+
const policyScopeRedisSchema = {
|
|
19
|
+
clientId: {
|
|
20
|
+
type: SCHEMA_FIELD_TYPE.TAG,
|
|
21
|
+
INDEXMISSING: true
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const accessRuleRedisSchema = {
|
|
25
|
+
...policyScopeRedisSchema,
|
|
26
|
+
...userScopeRedisSchema,
|
|
27
|
+
groupId: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true }
|
|
28
|
+
};
|
|
29
|
+
const ACCESS_RULES_REDIS_INDEX_NAME = "index:user-access-rules";
|
|
30
|
+
const ACCESS_RULE_REDIS_KEY_PREFIX = "uar:";
|
|
31
|
+
const accessRulesRedisIndex = {
|
|
32
|
+
name: ACCESS_RULES_REDIS_INDEX_NAME,
|
|
33
|
+
schema: accessRuleRedisSchema,
|
|
34
|
+
options: {
|
|
35
|
+
ON: "HASH",
|
|
36
|
+
PREFIX: [ACCESS_RULE_REDIS_KEY_PREFIX]
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const getAccessRuleRedisKey = (rule) => ACCESS_RULE_REDIS_KEY_PREFIX + makeAccessRuleHash(rule);
|
|
40
|
+
export {
|
|
41
|
+
ACCESS_RULES_REDIS_INDEX_NAME,
|
|
42
|
+
ACCESS_RULE_REDIS_KEY_PREFIX,
|
|
43
|
+
accessRuleRedisSchema,
|
|
44
|
+
accessRulesRedisIndex,
|
|
45
|
+
getAccessRuleRedisKey,
|
|
46
|
+
policyScopeRedisSchema,
|
|
47
|
+
userAttributesRedisSchema,
|
|
48
|
+
userIpRedisSchema,
|
|
49
|
+
userScopeRedisSchema
|
|
50
|
+
};
|
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { DummyRedisRulesReader, RedisRulesReader } from "./reader/redisRulesReader.js";
|
|
2
|
+
import { DummyRedisRulesWriter, RedisRulesWriter } from "./redisRulesWriter.js";
|
|
3
3
|
const createRedisAccessRulesStorage = (connection, logger) => {
|
|
4
|
-
const storage =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
const storage = composeStorage(
|
|
5
|
+
new DummyRedisRulesReader(logger),
|
|
6
|
+
new DummyRedisRulesWriter(logger)
|
|
7
|
+
);
|
|
8
8
|
connection.getClient().then((client) => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
const realStorage = composeStorage(
|
|
10
|
+
new RedisRulesReader(client, logger),
|
|
11
|
+
new RedisRulesWriter(client, logger)
|
|
12
|
+
);
|
|
13
|
+
Object.assign(storage, realStorage);
|
|
13
14
|
logger.info(() => ({
|
|
14
15
|
msg: "RedisAccessRules storage got a ready Redis client"
|
|
15
16
|
}));
|
|
16
17
|
});
|
|
17
18
|
return storage;
|
|
18
19
|
};
|
|
20
|
+
const composeStorage = (reader, writer) => ({
|
|
21
|
+
// reader
|
|
22
|
+
fetchRules: reader.fetchRules.bind(reader),
|
|
23
|
+
getMissingRuleIds: reader.getMissingRuleIds.bind(reader),
|
|
24
|
+
findRules: reader.findRules.bind(reader),
|
|
25
|
+
findRuleIds: reader.findRuleIds.bind(reader),
|
|
26
|
+
fetchAllRuleIds: reader.fetchAllRuleIds.bind(reader),
|
|
27
|
+
// writer
|
|
28
|
+
insertRules: writer.insertRules.bind(writer),
|
|
29
|
+
deleteRules: writer.deleteRules.bind(writer),
|
|
30
|
+
deleteAllRules: writer.deleteAllRules.bind(writer)
|
|
31
|
+
});
|
|
19
32
|
export {
|
|
20
33
|
createRedisAccessRulesStorage
|
|
21
34
|
};
|
|
@@ -1,73 +1,100 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { chunkIntoBatches, executeBatchesSequentially } from "@prosopo/common";
|
|
2
|
+
import { REDIS_BATCH_SIZE } from "./redisClient.js";
|
|
3
|
+
import { ACCESS_RULE_REDIS_KEY_PREFIX, getAccessRuleRedisKey } from "./redisRuleIndex.js";
|
|
4
|
+
class RedisRulesWriter {
|
|
5
|
+
constructor(client, logger) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
this.logger = logger;
|
|
8
|
+
}
|
|
9
|
+
async insertRules(ruleEntries) {
|
|
10
|
+
const entryBatches = chunkIntoBatches(ruleEntries, REDIS_BATCH_SIZE);
|
|
11
|
+
const keyBatches = await executeBatchesSequentially(
|
|
12
|
+
entryBatches,
|
|
13
|
+
async (entriesBatch) => this.insertRuleEntries(entriesBatch)
|
|
14
|
+
);
|
|
15
|
+
return keyBatches.flatMap(
|
|
16
|
+
(ruleKey) => ruleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
async deleteRules(ruleIds) {
|
|
20
|
+
const ruleKeys = ruleIds.map(
|
|
21
|
+
(ruleId) => ACCESS_RULE_REDIS_KEY_PREFIX + ruleId
|
|
22
|
+
);
|
|
23
|
+
const keyBatches = chunkIntoBatches(ruleKeys, REDIS_BATCH_SIZE);
|
|
24
|
+
await executeBatchesSequentially(keyBatches, async (keysBatch) => {
|
|
25
|
+
const queries = this.client.multi();
|
|
26
|
+
for (const ruleKey of keysBatch) {
|
|
27
|
+
queries.del(ruleKey);
|
|
28
|
+
}
|
|
29
|
+
await queries.exec();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async deleteAllRules() {
|
|
33
|
+
let cursor = "0";
|
|
34
|
+
let total = 0;
|
|
35
|
+
do {
|
|
36
|
+
const reply = await this.client.scan(cursor, {
|
|
37
|
+
MATCH: `${ACCESS_RULE_REDIS_KEY_PREFIX}*`,
|
|
38
|
+
COUNT: REDIS_BATCH_SIZE
|
|
39
|
+
});
|
|
40
|
+
const ids = reply.keys.map(
|
|
41
|
+
(key) => key.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length)
|
|
42
|
+
);
|
|
43
|
+
await this.deleteRules(ids);
|
|
44
|
+
total += ids.length;
|
|
45
|
+
cursor = reply.cursor;
|
|
46
|
+
} while ("0" !== cursor);
|
|
47
|
+
return total;
|
|
48
|
+
}
|
|
49
|
+
async insertRuleEntries(ruleEntries) {
|
|
50
|
+
const queries = this.client.multi();
|
|
51
|
+
const ruleKeys = ruleEntries.map((ruleEntry) => {
|
|
52
|
+
const { rule, expiresUnixTimestamp } = ruleEntry;
|
|
53
|
+
const ruleKey = getAccessRuleRedisKey(rule);
|
|
8
54
|
const ruleValue = getRedisRuleValue(rule);
|
|
9
|
-
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
if (expiryDate.getUTCFullYear() === 1970) {
|
|
13
|
-
await client.expireAt(ruleKey, expirationTimestamp);
|
|
14
|
-
} else {
|
|
15
|
-
const timestampInSeconds = Math.floor(expirationTimestamp / 1e3);
|
|
16
|
-
await client.expireAt(ruleKey, timestampInSeconds);
|
|
17
|
-
}
|
|
55
|
+
queries.hSet(ruleKey, ruleValue);
|
|
56
|
+
if (expiresUnixTimestamp) {
|
|
57
|
+
queries.expireAt(ruleKey, expiresUnixTimestamp);
|
|
18
58
|
}
|
|
19
59
|
return ruleKey;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return await client.del(keys);
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
const getDummyRedisRulesWriter = (logger) => {
|
|
30
|
-
return {
|
|
31
|
-
insertRule: async (rule, expirationTimestamp) => {
|
|
32
|
-
logger.info(() => ({
|
|
33
|
-
msg: "Dummy insertRule() has no effect (redis is not ready)",
|
|
34
|
-
data: {
|
|
35
|
-
rule
|
|
36
|
-
}
|
|
37
|
-
}));
|
|
38
|
-
return "";
|
|
39
|
-
},
|
|
40
|
-
deleteRules: async (ruleIds) => {
|
|
41
|
-
logger.info(() => ({
|
|
42
|
-
msg: "Dummy deleteRules() has no effect (redis is not ready)",
|
|
43
|
-
data: {
|
|
44
|
-
ruleIds
|
|
45
|
-
}
|
|
46
|
-
}));
|
|
47
|
-
},
|
|
48
|
-
deleteAllRules: async () => {
|
|
49
|
-
logger.info(() => ({
|
|
50
|
-
msg: "Dummy deleteAllRules() has no effect (redis is not ready)"
|
|
51
|
-
}));
|
|
52
|
-
return 0;
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
};
|
|
56
|
-
const getRedisRuleKey = (rule) => redisRuleKeyPrefix + crypto.createHash(redisRuleContentHashAlgorithm).update(
|
|
57
|
-
JSON.stringify(
|
|
58
|
-
rule,
|
|
59
|
-
(key, value) => (
|
|
60
|
-
// JSON.stringify can't handle BigInt itself: throws "Do not know how to serialize a BigInt"
|
|
61
|
-
"bigint" === typeof value ? value.toString() : value
|
|
62
|
-
)
|
|
63
|
-
)
|
|
64
|
-
).digest("hex");
|
|
60
|
+
});
|
|
61
|
+
await queries.exec();
|
|
62
|
+
return ruleKeys;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
65
|
const getRedisRuleValue = (rule) => Object.fromEntries(
|
|
66
66
|
Object.entries(rule).map(([key, value]) => [key, String(value)])
|
|
67
67
|
);
|
|
68
|
+
class DummyRedisRulesWriter {
|
|
69
|
+
constructor(logger) {
|
|
70
|
+
this.logger = logger;
|
|
71
|
+
}
|
|
72
|
+
async insertRules(ruleEntries) {
|
|
73
|
+
this.logger.info(() => ({
|
|
74
|
+
msg: "Dummy insertRules() has no effect (redis is not ready)",
|
|
75
|
+
data: {
|
|
76
|
+
ruleEntries
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
async deleteRules(ruleIds) {
|
|
82
|
+
this.logger.info(() => ({
|
|
83
|
+
msg: "Dummy deleteRules() has no effect (redis is not ready)",
|
|
84
|
+
data: {
|
|
85
|
+
ruleIds
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
async deleteAllRules() {
|
|
90
|
+
this.logger.info(() => ({
|
|
91
|
+
msg: "Dummy deleteAllRules() has no effect (redis is not ready)"
|
|
92
|
+
}));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
68
96
|
export {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
getRedisRuleKey,
|
|
97
|
+
DummyRedisRulesWriter,
|
|
98
|
+
RedisRulesWriter,
|
|
72
99
|
getRedisRuleValue
|
|
73
100
|
};
|
package/dist/rule.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { accessRuleInput } from "./ruleInput.js";
|
|
2
|
+
import { accessPolicyInput, policyScopeInput } from "./policyInput.js";
|
|
3
|
+
import { userScopeInput } from "./userScopeInput.js";
|
|
4
|
+
export {
|
|
5
|
+
accessPolicyInput,
|
|
6
|
+
accessRuleInput,
|
|
7
|
+
policyScopeInput,
|
|
8
|
+
userScopeInput
|
|
9
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CaptchaTypeSchema } from "@prosopo/types";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { AccessPolicyType } from "../rule.js";
|
|
4
|
+
const accessPolicyInput = z.object({
|
|
5
|
+
type: z.nativeEnum(AccessPolicyType),
|
|
6
|
+
captchaType: CaptchaTypeSchema.optional(),
|
|
7
|
+
description: z.coerce.string().optional(),
|
|
8
|
+
// Redis stores values as strings, so coerce is needed to parse properly
|
|
9
|
+
solvedImagesCount: z.coerce.number().optional(),
|
|
10
|
+
// the percentage of image panels that must be solved per image CAPTCHA
|
|
11
|
+
imageThreshold: z.coerce.number().optional(),
|
|
12
|
+
// the Proof-of-Work difficulty level
|
|
13
|
+
powDifficulty: z.coerce.number().optional(),
|
|
14
|
+
// the number of unsolved image CAPTCHA challenges to serve
|
|
15
|
+
unsolvedImagesCount: z.coerce.number().optional(),
|
|
16
|
+
// used to increase the user's score
|
|
17
|
+
frictionlessScore: z.coerce.number().optional()
|
|
18
|
+
});
|
|
19
|
+
const policyScopeInput = z.object({
|
|
20
|
+
clientId: z.coerce.string().optional()
|
|
21
|
+
});
|
|
22
|
+
export {
|
|
23
|
+
accessPolicyInput,
|
|
24
|
+
policyScopeInput
|
|
25
|
+
};
|