@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/.export.js +21 -0
  3. package/dist/api/.export.js +11 -0
  4. package/dist/api/delete/.export.js +1 -0
  5. package/dist/api/{deleteAllRulesEndpoint.js → delete/deleteAllRules.js} +10 -9
  6. package/dist/api/delete/deleteRuleGroups.js +52 -0
  7. package/dist/api/delete/deleteRules.js +43 -0
  8. package/dist/api/read/.export.js +1 -0
  9. package/dist/api/read/fetchRules.js +43 -0
  10. package/dist/api/read/findRuleIds.js +50 -0
  11. package/dist/api/read/getMissingIds.js +41 -0
  12. package/dist/api/ruleApiRoutes.js +131 -0
  13. package/dist/api/rulesApiClient.js +93 -0
  14. package/dist/api/write/.export.js +1 -0
  15. package/dist/api/write/insertRules.js +102 -0
  16. package/dist/api/write/rehashRules.js +57 -0
  17. package/dist/cjs/.export.cjs +21 -0
  18. package/dist/cjs/api/.export.cjs +11 -0
  19. package/dist/cjs/api/delete/.export.cjs +1 -0
  20. package/dist/cjs/api/{deleteAllRulesEndpoint.cjs → delete/deleteAllRules.cjs} +9 -8
  21. package/dist/cjs/api/delete/deleteRuleGroups.cjs +52 -0
  22. package/dist/cjs/api/delete/deleteRules.cjs +43 -0
  23. package/dist/cjs/api/read/.export.cjs +1 -0
  24. package/dist/cjs/api/read/fetchRules.cjs +43 -0
  25. package/dist/cjs/api/read/findRuleIds.cjs +50 -0
  26. package/dist/cjs/api/read/getMissingIds.cjs +41 -0
  27. package/dist/cjs/api/ruleApiRoutes.cjs +131 -0
  28. package/dist/cjs/api/rulesApiClient.cjs +93 -0
  29. package/dist/cjs/api/write/.export.cjs +1 -0
  30. package/dist/cjs/api/write/insertRules.cjs +102 -0
  31. package/dist/cjs/api/write/rehashRules.cjs +57 -0
  32. package/dist/cjs/mongoose/.export.cjs +4 -0
  33. package/dist/cjs/mongoose/mongooseRuleSchema.cjs +36 -0
  34. package/dist/cjs/redis/.export.cjs +6 -0
  35. package/dist/cjs/redis/reader/redisAggregate.cjs +60 -0
  36. package/dist/cjs/redis/reader/redisRulesQuery.cjs +99 -0
  37. package/dist/cjs/redis/reader/redisRulesReader.cjs +230 -0
  38. package/dist/cjs/redis/redisClient.cjs +67 -0
  39. package/dist/cjs/redis/redisRuleIndex.cjs +50 -0
  40. package/dist/cjs/redis/redisRulesStorage.cjs +22 -9
  41. package/dist/cjs/redis/redisRulesWriter.cjs +91 -64
  42. package/dist/cjs/rule.cjs +8 -0
  43. package/dist/cjs/ruleInput/.export.cjs +9 -0
  44. package/dist/cjs/ruleInput/policyInput.cjs +25 -0
  45. package/dist/cjs/ruleInput/ruleInput.cjs +50 -0
  46. package/dist/cjs/ruleInput/userScopeInput.cjs +55 -0
  47. package/dist/cjs/ruleRecord.cjs +23 -0
  48. package/dist/cjs/rulesStorage.cjs +8 -0
  49. package/dist/cjs/transformRule.cjs +77 -0
  50. package/dist/mongoose/.export.js +4 -0
  51. package/dist/mongoose/mongooseRuleSchema.js +36 -0
  52. package/dist/redis/.export.js +6 -0
  53. package/dist/redis/reader/redisAggregate.js +60 -0
  54. package/dist/redis/reader/redisRulesQuery.js +99 -0
  55. package/dist/redis/reader/redisRulesReader.js +213 -0
  56. package/dist/redis/redisClient.js +67 -0
  57. package/dist/redis/redisRuleIndex.js +50 -0
  58. package/dist/redis/redisRulesStorage.js +23 -10
  59. package/dist/redis/redisRulesWriter.js +91 -64
  60. package/dist/rule.js +8 -0
  61. package/dist/ruleInput/.export.js +9 -0
  62. package/dist/ruleInput/policyInput.js +25 -0
  63. package/dist/ruleInput/ruleInput.js +50 -0
  64. package/dist/ruleInput/userScopeInput.js +55 -0
  65. package/dist/ruleRecord.js +23 -0
  66. package/dist/rulesStorage.js +8 -0
  67. package/dist/transformRule.js +77 -0
  68. package/entries.ts +20 -0
  69. package/package.json +34 -18
  70. package/vite.cjs.config.ts +4 -1
  71. package/vite.esm.config.ts +6 -1
  72. package/dist/accessPolicy.js +0 -80
  73. package/dist/accessPolicyResolver.js +0 -31
  74. package/dist/accessRules.js +0 -11
  75. package/dist/api/accessRuleApiRoutes.js +0 -79
  76. package/dist/api/accessRulesApiClient.js +0 -38
  77. package/dist/api/deleteRulesEndpoint.js +0 -34
  78. package/dist/api/insertRulesEndpoint.js +0 -62
  79. package/dist/cjs/accessPolicy.cjs +0 -80
  80. package/dist/cjs/accessPolicyResolver.cjs +0 -31
  81. package/dist/cjs/accessRules.cjs +0 -11
  82. package/dist/cjs/api/accessRuleApiRoutes.cjs +0 -79
  83. package/dist/cjs/api/accessRulesApiClient.cjs +0 -38
  84. package/dist/cjs/api/deleteRulesEndpoint.cjs +0 -34
  85. package/dist/cjs/api/insertRulesEndpoint.cjs +0 -62
  86. package/dist/cjs/index.cjs +0 -31
  87. package/dist/cjs/redis/redisRulesIndex.cjs +0 -138
  88. package/dist/cjs/redis/redisRulesReader.cjs +0 -142
  89. package/dist/cjs/util.cjs +0 -5
  90. package/dist/index.js +0 -32
  91. package/dist/redis/redisRulesIndex.js +0 -138
  92. package/dist/redis/redisRulesReader.js +0 -125
  93. 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 { getDummyRedisRulesReader, createRedisRulesReader } from "./redisRulesReader.js";
2
- import { getDummyRedisRulesWriter, createRedisRulesWriter } from "./redisRulesWriter.js";
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
- ...getDummyRedisRulesReader(logger),
6
- ...getDummyRedisRulesWriter(logger)
7
- };
4
+ const storage = composeStorage(
5
+ new DummyRedisRulesReader(logger),
6
+ new DummyRedisRulesWriter(logger)
7
+ );
8
8
  connection.getClient().then((client) => {
9
- Object.assign(storage, {
10
- ...createRedisRulesReader(client, logger),
11
- ...createRedisRulesWriter(client)
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 crypto from "node:crypto";
2
- import { redisRuleKeyPrefix } from "./redisRulesIndex.js";
3
- const redisRuleContentHashAlgorithm = "md5";
4
- const createRedisRulesWriter = (client) => {
5
- return {
6
- insertRule: async (rule, expirationTimestamp) => {
7
- const ruleKey = getRedisRuleKey(rule);
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
- await client.hSet(ruleKey, ruleValue);
10
- if (expirationTimestamp) {
11
- const expiryDate = new Date(expirationTimestamp);
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
- deleteRules: async (ruleIds) => void await client.del(ruleIds),
22
- deleteAllRules: async () => {
23
- const keys = await client.keys(`${redisRuleKeyPrefix}*`);
24
- if (keys.length === 0) return 0;
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
- createRedisRulesWriter,
70
- getDummyRedisRulesWriter,
71
- getRedisRuleKey,
97
+ DummyRedisRulesWriter,
98
+ RedisRulesWriter,
72
99
  getRedisRuleValue
73
100
  };
package/dist/rule.js ADDED
@@ -0,0 +1,8 @@
1
+ var AccessPolicyType = /* @__PURE__ */ ((AccessPolicyType2) => {
2
+ AccessPolicyType2["Block"] = "block";
3
+ AccessPolicyType2["Restrict"] = "restrict";
4
+ return AccessPolicyType2;
5
+ })(AccessPolicyType || {});
6
+ export {
7
+ AccessPolicyType
8
+ };
@@ -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
+ };