@prosopo/user-access-policy 3.12.7 → 3.12.9
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 +45 -41
- package/.turbo/turbo-build$colon$tsc.log +14 -14
- package/.turbo/turbo-build.log +46 -42
- package/CHANGELOG.md +40 -0
- package/dist/.export.js +7 -21
- package/dist/_virtual/_rolldown/runtime.js +3 -0
- package/dist/api/.export.js +2 -9
- package/dist/api/accessRulesApiClient.js +1 -1
- package/dist/api/delete/.export.js +2 -1
- package/dist/api/delete/deleteAllRules.js +22 -24
- package/dist/api/delete/deleteRuleGroups.js +39 -51
- package/dist/api/delete/deleteRules.js +37 -41
- package/dist/api/read/.export.js +2 -1
- package/dist/api/read/fetchRules.js +34 -42
- package/dist/api/read/findRuleIds.js +35 -48
- package/dist/api/read/getMissingIds.js +36 -40
- package/dist/api/ruleApiRoutes.js +62 -120
- package/dist/api/rulesApiClient.js +51 -87
- package/dist/api/write/.export.js +2 -1
- package/dist/api/write/insertRules.js +84 -102
- package/dist/api/write/rehashRules.js +48 -56
- package/dist/cjs/.export.cjs +21 -23
- package/dist/cjs/_virtual/_rolldown/runtime.cjs +23 -0
- package/dist/cjs/api/.export.cjs +6 -10
- package/dist/cjs/api/accessRulesApiClient.cjs +0 -0
- package/dist/cjs/api/delete/.export.cjs +0 -1
- package/dist/cjs/api/delete/deleteAllRules.cjs +22 -24
- package/dist/cjs/api/delete/deleteRuleGroups.cjs +39 -51
- package/dist/cjs/api/delete/deleteRules.cjs +41 -42
- package/dist/cjs/api/read/.export.cjs +0 -1
- package/dist/cjs/api/read/fetchRules.cjs +34 -41
- package/dist/cjs/api/read/findRuleIds.cjs +37 -48
- package/dist/cjs/api/read/getMissingIds.cjs +36 -39
- package/dist/cjs/api/ruleApiRoutes.cjs +69 -125
- package/dist/cjs/api/rulesApiClient.cjs +54 -90
- package/dist/cjs/api/write/.export.cjs +0 -1
- package/dist/cjs/api/write/insertRules.cjs +85 -103
- package/dist/cjs/api/write/rehashRules.cjs +48 -56
- package/dist/cjs/classifyOs.cjs +46 -26
- package/dist/cjs/mongoose/.export.cjs +2 -3
- package/dist/cjs/mongoose/mongooseRuleSchema.cjs +97 -36
- package/dist/cjs/redis/.export.cjs +4 -5
- package/dist/cjs/redis/reader/redisAggregate.cjs +59 -84
- package/dist/cjs/redis/reader/redisRulesQuery.cjs +74 -116
- package/dist/cjs/redis/reader/redisRulesReader.cjs +322 -503
- package/dist/cjs/redis/reader/redisRulesSplitQuery.cjs +94 -72
- package/dist/cjs/redis/redisClient.cjs +53 -58
- package/dist/cjs/redis/redisRuleIndex.cjs +93 -49
- package/dist/cjs/redis/redisRulesStorage.cjs +21 -30
- package/dist/cjs/redis/redisRulesWriter.cjs +86 -112
- package/dist/cjs/rule.cjs +8 -7
- package/dist/cjs/ruleInput/.export.cjs +7 -8
- package/dist/cjs/ruleInput/policyInput.cjs +36 -39
- package/dist/cjs/ruleInput/ruleInput.cjs +37 -42
- package/dist/cjs/ruleInput/userScopeInput.cjs +47 -55
- package/dist/cjs/ruleRecord.cjs +15 -22
- package/dist/cjs/rulesStorage.cjs +7 -6
- package/dist/cjs/transformRule.cjs +36 -70
- package/dist/classifyOs.js +48 -29
- package/dist/mongoose/.export.js +2 -3
- package/dist/mongoose/mongooseRuleSchema.js +99 -38
- package/dist/redis/.export.js +3 -5
- package/dist/redis/reader/redisAggregate.js +60 -86
- package/dist/redis/reader/redisRulesQuery.js +75 -118
- package/dist/redis/reader/redisRulesReader.js +312 -487
- package/dist/redis/reader/redisRulesSplitQuery.js +96 -75
- package/dist/redis/redisClient.js +54 -62
- package/dist/redis/redisRuleIndex.js +93 -58
- package/dist/redis/redisRulesStorage.js +21 -30
- package/dist/redis/redisRulesWriter.js +82 -113
- package/dist/rule.js +10 -10
- package/dist/ruleInput/.export.js +3 -7
- package/dist/ruleInput/policyInput.d.ts +52 -1
- package/dist/ruleInput/policyInput.d.ts.map +1 -1
- package/dist/ruleInput/policyInput.js +35 -41
- package/dist/ruleInput/policyInput.js.map +1 -1
- package/dist/ruleInput/ruleInput.d.ts.map +1 -1
- package/dist/ruleInput/ruleInput.js +36 -44
- package/dist/ruleInput/ruleInput.js.map +1 -1
- package/dist/ruleInput/userScopeInput.js +44 -55
- package/dist/ruleRecord.js +17 -27
- package/dist/rulesStorage.js +9 -8
- package/dist/tests/policyInput.unit.test.js +50 -1
- package/dist/tests/policyInput.unit.test.js.map +1 -1
- package/dist/tests/redis/redisRulesStorage.integration.test.js +22 -3
- package/dist/tests/redis/redisRulesStorage.integration.test.js.map +1 -1
- package/dist/transformRule.js +34 -73
- package/dist/transformRule.js.map +1 -1
- package/entries.ts +25 -6
- package/package.json +13 -12
- package/src/ruleInput/policyInput.ts +45 -2
- package/src/ruleInput/ruleInput.ts +10 -3
- package/src/tests/policyInput.unit.test.ts +69 -1
- package/src/tests/redis/redisRulesStorage.integration.test.ts +191 -148
- package/src/transformRule.ts +2 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -27,7 +27,24 @@ import {
|
|
|
27
27
|
// (preprocess accepts anything), which fails the
|
|
28
28
|
// `ZodType<T, ZodTypeDef, T>` identity check. The `AllKeys<AccessPolicy>`
|
|
29
29
|
// constraint still catches missing-field regressions.
|
|
30
|
-
|
|
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({
|
|
31
48
|
type: z.nativeEnum(AccessPolicyType),
|
|
32
49
|
captchaType: CaptchaTypeSchema.optional(),
|
|
33
50
|
description: z.coerce.string().optional(),
|
|
@@ -49,7 +66,33 @@ export const accessPolicyInput = z.object({
|
|
|
49
66
|
.optional(),
|
|
50
67
|
} satisfies AllKeys<AccessPolicy>);
|
|
51
68
|
|
|
52
|
-
|
|
69
|
+
export const accessPolicyInput = accessPolicyInputShape.superRefine(
|
|
70
|
+
(policy, ctx) => {
|
|
71
|
+
if (policy.type !== AccessPolicyType.Block) return;
|
|
72
|
+
if (policy.captchaType !== undefined) {
|
|
73
|
+
ctx.addIssue({
|
|
74
|
+
code: z.ZodIssueCode.custom,
|
|
75
|
+
path: ["captchaType"],
|
|
76
|
+
message:
|
|
77
|
+
"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.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (policy.solvedImagesCount !== undefined) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: z.ZodIssueCode.custom,
|
|
83
|
+
path: ["solvedImagesCount"],
|
|
84
|
+
message:
|
|
85
|
+
"Block policies cannot set solvedImagesCount — the field only applies to Restrict policies that adjust image-captcha rounds.",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Historical helper: strip fields the sanitizer used to remove silently.
|
|
92
|
+
// Kept as a no-op-in-practice safety net because Redis records written
|
|
93
|
+
// before the superRefine on `accessPolicyInput` (above) may still carry
|
|
94
|
+
// the stripped fields — reading them back should still normalize to the
|
|
95
|
+
// canonical shape. New writes are rejected at input.
|
|
53
96
|
export const sanitizeAccessPolicy = (policy: AccessPolicy): AccessPolicy => {
|
|
54
97
|
if (policy.type === AccessPolicyType.Block) {
|
|
55
98
|
const { captchaType, solvedImagesCount, ...blockPolicy } = policy;
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type AccessRulesFilter,
|
|
21
21
|
FilterScopeMatch,
|
|
22
22
|
} from "#policy/rulesStorage.js";
|
|
23
|
-
import {
|
|
23
|
+
import { accessPolicyInputShape, policyScopeInput } from "./policyInput.js";
|
|
24
24
|
import { type UserScopeInput, userScopeInput } from "./userScopeInput.js";
|
|
25
25
|
|
|
26
26
|
type RuleGroupInput = {
|
|
@@ -49,13 +49,20 @@ const ruleGroupInput = z
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
// Explicit `ZodType<…, ZodTypeDef, unknown>` annotation rather than the
|
|
52
|
-
// strict-identity form because `
|
|
52
|
+
// strict-identity form because `accessPolicyInputShape.shape.deferToVerify`
|
|
53
53
|
// uses `z.preprocess` which widens the input position to `unknown`. The
|
|
54
54
|
// relaxed annotation is portable for declaration emit; the `transform`
|
|
55
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.
|
|
56
63
|
export const accessRuleInput: ZodType<AccessRule, z.ZodTypeDef, unknown> = z
|
|
57
64
|
.object({
|
|
58
|
-
...
|
|
65
|
+
...accessPolicyInputShape.shape,
|
|
59
66
|
...policyScopeInput.shape,
|
|
60
67
|
})
|
|
61
68
|
.and(userScopeInput)
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
import { CaptchaType } from "@prosopo/types";
|
|
16
16
|
import { describe, expect, it } from "vitest";
|
|
17
17
|
import { AccessPolicyType } from "#policy/rule.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
accessPolicyInput,
|
|
20
|
+
sanitizeAccessPolicy,
|
|
21
|
+
} from "#policy/ruleInput/policyInput.js";
|
|
19
22
|
|
|
20
23
|
describe("sanitizeAccessPolicy", () => {
|
|
21
24
|
describe("block policies", () => {
|
|
@@ -148,3 +151,68 @@ describe("sanitizeAccessPolicy", () => {
|
|
|
148
151
|
});
|
|
149
152
|
});
|
|
150
153
|
});
|
|
154
|
+
|
|
155
|
+
// Write-time input schema — reject the fields the sanitiser used to
|
|
156
|
+
// silently strip. The prior "silently strip" behaviour meant operators
|
|
157
|
+
// writing `--block --captchaType image` got a rule that blocked EVERY
|
|
158
|
+
// captcha type (not just image); rejecting at input surfaces the
|
|
159
|
+
// mismatch so they can switch to a Restrict policy instead.
|
|
160
|
+
describe("accessPolicyInput refinement", () => {
|
|
161
|
+
it("rejects Block + captchaType with a clear message pointing at Restrict", () => {
|
|
162
|
+
const result = accessPolicyInput.safeParse({
|
|
163
|
+
type: AccessPolicyType.Block,
|
|
164
|
+
captchaType: CaptchaType.image,
|
|
165
|
+
});
|
|
166
|
+
expect(result.success).toBe(false);
|
|
167
|
+
if (!result.success) {
|
|
168
|
+
const issue = result.error.issues.find((i) =>
|
|
169
|
+
i.path.includes("captchaType"),
|
|
170
|
+
);
|
|
171
|
+
expect(issue).toBeDefined();
|
|
172
|
+
expect(issue?.message).toMatch(/Block policies cannot pin a captchaType/);
|
|
173
|
+
expect(issue?.message).toMatch(/Restrict policy/);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects Block + solvedImagesCount", () => {
|
|
178
|
+
const result = accessPolicyInput.safeParse({
|
|
179
|
+
type: AccessPolicyType.Block,
|
|
180
|
+
solvedImagesCount: 3,
|
|
181
|
+
});
|
|
182
|
+
expect(result.success).toBe(false);
|
|
183
|
+
if (!result.success) {
|
|
184
|
+
const issue = result.error.issues.find((i) =>
|
|
185
|
+
i.path.includes("solvedImagesCount"),
|
|
186
|
+
);
|
|
187
|
+
expect(issue).toBeDefined();
|
|
188
|
+
expect(issue?.message).toMatch(
|
|
189
|
+
/Block policies cannot set solvedImagesCount/,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("accepts Block without the disallowed fields", () => {
|
|
195
|
+
const result = accessPolicyInput.safeParse({
|
|
196
|
+
type: AccessPolicyType.Block,
|
|
197
|
+
description: "clean block",
|
|
198
|
+
});
|
|
199
|
+
expect(result.success).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("accepts Block + deferToVerify (deferToVerify is orthogonal to the type-pin restriction)", () => {
|
|
203
|
+
const result = accessPolicyInput.safeParse({
|
|
204
|
+
type: AccessPolicyType.Block,
|
|
205
|
+
deferToVerify: true,
|
|
206
|
+
});
|
|
207
|
+
expect(result.success).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("accepts Restrict + captchaType + solvedImagesCount (these are Restrict's whole point)", () => {
|
|
211
|
+
const result = accessPolicyInput.safeParse({
|
|
212
|
+
type: AccessPolicyType.Restrict,
|
|
213
|
+
captchaType: CaptchaType.image,
|
|
214
|
+
solvedImagesCount: 5,
|
|
215
|
+
});
|
|
216
|
+
expect(result.success).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -113,176 +113,219 @@ describe("redisAccessRulesStorage", () => {
|
|
|
113
113
|
redisClient = await result.getClient();
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
describe(
|
|
117
|
-
|
|
118
|
-
() => {
|
|
119
|
-
let accessRulesWriter: AccessRulesWriter;
|
|
116
|
+
describe("writer", () => {
|
|
117
|
+
let accessRulesWriter: AccessRulesWriter;
|
|
120
118
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
beforeAll(() => {
|
|
120
|
+
accessRulesWriter = new RedisRulesWriter(redisClient, mockLogger);
|
|
121
|
+
});
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
test("inserts rule", async () => {
|
|
124
|
+
const testIndexName = indexName;
|
|
125
|
+
// given
|
|
126
|
+
const accessRule: AccessRule = {
|
|
127
|
+
type: AccessPolicyType.Block,
|
|
128
|
+
clientId: "clientId",
|
|
129
|
+
};
|
|
130
|
+
const accessRuleKey = getAccessRuleRedisKey(accessRule);
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
132
|
+
// when
|
|
133
|
+
await accessRulesWriter.insertRules([
|
|
134
|
+
{
|
|
135
|
+
rule: accessRule,
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
140
138
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
// then
|
|
140
|
+
const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
|
|
141
|
+
const indexRecordsCount = await getIndexRecordsCount(testIndexName);
|
|
144
142
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
expect(insertedAccessRule).toEqual(accessRule);
|
|
144
|
+
expect(indexRecordsCount).toEqual(1);
|
|
145
|
+
});
|
|
148
146
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
147
|
+
test("inserts time limited rule", async () => {
|
|
148
|
+
// given
|
|
149
|
+
const accessRule: AccessRule = {
|
|
150
|
+
type: AccessPolicyType.Block,
|
|
151
|
+
clientId: "clientId",
|
|
152
|
+
};
|
|
153
|
+
const accessRuleKey = getAccessRuleRedisKey(accessRule);
|
|
154
|
+
// 1 hour from now.
|
|
155
|
+
const expireIn = 60 * 60; // seconds
|
|
156
|
+
const expirationTimestamp = new Date(
|
|
157
|
+
Date.now() + expireIn * 1000,
|
|
158
|
+
).getTime();
|
|
159
|
+
const expirationTimestampInSeconds = Math.floor(
|
|
160
|
+
expirationTimestamp / 1000,
|
|
161
|
+
);
|
|
164
162
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
163
|
+
// when
|
|
164
|
+
await accessRulesWriter.insertRules([
|
|
165
|
+
{
|
|
166
|
+
rule: accessRule,
|
|
167
|
+
expiresUnixTimestamp: expirationTimestampInSeconds,
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
const ruleKey = getAccessRuleRedisKey(accessRule);
|
|
171
|
+
// then
|
|
172
|
+
const insertedAccessRule = await redisClient.hGetAll(accessRuleKey);
|
|
173
|
+
const insertedExpirationResult = await redisClient.expireAt(
|
|
174
|
+
ruleKey,
|
|
175
|
+
expirationTimestampInSeconds,
|
|
176
|
+
);
|
|
177
|
+
const indexRecordsCount = await getIndexRecordsCount(indexName);
|
|
180
178
|
|
|
181
|
-
|
|
179
|
+
const recordExpirySeconds = await redisClient.ttl(ruleKey);
|
|
182
180
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
181
|
+
expect(insertedAccessRule).toEqual(accessRule);
|
|
182
|
+
expect(insertedExpirationResult).toBe(1);
|
|
183
|
+
expect(recordExpirySeconds).toBeLessThanOrEqual(
|
|
184
|
+
expirationTimestampInSeconds,
|
|
185
|
+
);
|
|
188
186
|
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
expect(indexRecordsCount).toBe(1);
|
|
188
|
+
});
|
|
191
189
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
190
|
+
// The existing "inserts time limited rule" test above only
|
|
191
|
+
// checks that the TTL is *set* on the Redis key. Neither
|
|
192
|
+
// side actually verifies the rule stops matching once its
|
|
193
|
+
// TTL fires — which is the property operators care about
|
|
194
|
+
// (temporary bans should self-clear). Guard against a
|
|
195
|
+
// regression that persists rules past their expiry (e.g. a
|
|
196
|
+
// future writer that swaps `expireAt` for `persist` by
|
|
197
|
+
// accident, or an index rebuild that resurrects expired
|
|
198
|
+
// records).
|
|
199
|
+
test("expired rule is no longer returned by the reader after its TTL fires", async () => {
|
|
200
|
+
const accessRule: AccessRule = {
|
|
201
|
+
type: AccessPolicyType.Block,
|
|
202
|
+
clientId: `expiring-${getUniqueString()}`,
|
|
203
|
+
};
|
|
204
|
+
const ruleKey = getAccessRuleRedisKey(accessRule);
|
|
205
|
+
// 2 s expiry — long enough that the "before expiry" check
|
|
206
|
+
// races the write cleanly, short enough that the test
|
|
207
|
+
// completes in a reasonable timeout. Redis TTL granularity
|
|
208
|
+
// is 1 s so anything shorter is under-resolution.
|
|
209
|
+
const expirationTimestampInSeconds = Math.floor(Date.now() / 1000) + 2;
|
|
199
210
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
await accessRulesWriter.insertRules([
|
|
212
|
+
{
|
|
213
|
+
rule: accessRule,
|
|
214
|
+
expiresUnixTimestamp: expirationTimestampInSeconds,
|
|
215
|
+
},
|
|
216
|
+
]);
|
|
205
217
|
|
|
206
|
-
|
|
218
|
+
// Sanity: the rule exists immediately after write.
|
|
219
|
+
const initialRecord = await redisClient.hGetAll(ruleKey);
|
|
220
|
+
expect(initialRecord).toEqual(accessRule);
|
|
207
221
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
222
|
+
// Wait past the TTL. Add ~1s slack for Redis's per-key
|
|
223
|
+
// expiry sweep — the docs guarantee expired keys stop
|
|
224
|
+
// answering reads but the actual key deletion is lazy.
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
212
226
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
227
|
+
// After expiry: hGetAll returns {} (Redis treats an
|
|
228
|
+
// expired key as non-existent for reads).
|
|
229
|
+
const expiredRecord = await redisClient.hGetAll(ruleKey);
|
|
230
|
+
expect(expiredRecord).toEqual({});
|
|
216
231
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
// And the RediSearch index no longer counts it.
|
|
233
|
+
const indexRecordsCount = await getIndexRecordsCount(indexName);
|
|
234
|
+
expect(indexRecordsCount).toBe(0);
|
|
235
|
+
}, 10_000);
|
|
220
236
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
type: AccessPolicyType.Block,
|
|
229
|
-
clientId: getUniqueString(),
|
|
230
|
-
};
|
|
237
|
+
test("deletes rules", async () => {
|
|
238
|
+
// given
|
|
239
|
+
const johnAccessRule: AccessRule = {
|
|
240
|
+
type: AccessPolicyType.Block,
|
|
241
|
+
clientId: getUniqueString(),
|
|
242
|
+
};
|
|
243
|
+
const johnAccessRuleKey = getAccessRuleRedisKey(johnAccessRule);
|
|
231
244
|
|
|
232
|
-
|
|
245
|
+
const doeAccessRule: AccessRule = {
|
|
246
|
+
type: AccessPolicyType.Block,
|
|
247
|
+
clientId: getUniqueString(),
|
|
248
|
+
};
|
|
249
|
+
const doeAccessRuleKey = getAccessRuleRedisKey(doeAccessRule);
|
|
233
250
|
|
|
234
|
-
|
|
235
|
-
await accessRulesWriter.deleteAllRules();
|
|
251
|
+
await insertRules([johnAccessRule, doeAccessRule]);
|
|
236
252
|
|
|
237
|
-
|
|
238
|
-
|
|
253
|
+
// when
|
|
254
|
+
await accessRulesWriter.deleteRules([
|
|
255
|
+
johnAccessRuleKey.slice(ACCESS_RULE_REDIS_KEY_PREFIX.length),
|
|
256
|
+
]);
|
|
239
257
|
|
|
240
|
-
|
|
241
|
-
|
|
258
|
+
// then
|
|
259
|
+
const presentAccessRule = await redisClient.hGetAll(doeAccessRuleKey);
|
|
260
|
+
const indexRecordsCount = await getIndexRecordsCount(indexName);
|
|
242
261
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
262
|
+
expect(presentAccessRule).toEqual(doeAccessRule);
|
|
263
|
+
expect(indexRecordsCount).toBe(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("deletes all rules", async () => {
|
|
267
|
+
// given
|
|
268
|
+
const johnAccessRule: AccessRule = {
|
|
269
|
+
type: AccessPolicyType.Block,
|
|
270
|
+
clientId: getUniqueString(),
|
|
271
|
+
};
|
|
272
|
+
const doeAccessRule: AccessRule = {
|
|
273
|
+
type: AccessPolicyType.Block,
|
|
274
|
+
clientId: getUniqueString(),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
await insertRules([johnAccessRule, doeAccessRule]);
|
|
278
|
+
|
|
279
|
+
// when
|
|
280
|
+
await accessRulesWriter.deleteAllRules();
|
|
281
|
+
|
|
282
|
+
// then
|
|
283
|
+
const indexRecordsCount = await getIndexRecordsCount(indexName);
|
|
284
|
+
|
|
285
|
+
expect(indexRecordsCount).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("deletes all rules when there are 1 million rules", async () => {
|
|
289
|
+
// given
|
|
290
|
+
const rulesCount = 1_000_000;
|
|
291
|
+
const batchSize = 10_000;
|
|
292
|
+
const numBatches = Math.ceil(rulesCount / batchSize);
|
|
293
|
+
|
|
294
|
+
// Insert rules in batches to avoid memory exhaustion
|
|
295
|
+
// Don't create 1M objects in memory at once!
|
|
296
|
+
for (let i = 0; i < numBatches; i++) {
|
|
297
|
+
const currentBatchSize = Math.min(
|
|
298
|
+
batchSize,
|
|
299
|
+
rulesCount - i * batchSize,
|
|
300
|
+
);
|
|
301
|
+
const batchRules: AccessRule[] = Array.from(
|
|
302
|
+
{ length: currentBatchSize },
|
|
303
|
+
() => ({
|
|
304
|
+
type: AccessPolicyType.Block,
|
|
305
|
+
clientId: getUniqueString(),
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
await insertRules(batchRules);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// verify that there are 1 million rules in the database
|
|
313
|
+
const beforeDeleteIndexRecordsCount =
|
|
314
|
+
await getIndexRecordsCount(indexName);
|
|
315
|
+
expect(beforeDeleteIndexRecordsCount).toBe(rulesCount);
|
|
316
|
+
|
|
317
|
+
// when
|
|
318
|
+
await accessRulesWriter.deleteAllRules();
|
|
319
|
+
|
|
320
|
+
// then
|
|
321
|
+
const afterDeleteIndexRecordsCount =
|
|
322
|
+
await getIndexRecordsCount(indexName);
|
|
323
|
+
|
|
324
|
+
expect(afterDeleteIndexRecordsCount).toBe(0);
|
|
325
|
+
});
|
|
326
|
+
// The trailing 240_000 is the suite timeout. vitest 4 dropped the
|
|
327
|
+
// options-object overload for describe, so it has to be a plain number.
|
|
328
|
+
}, 240_000);
|
|
286
329
|
|
|
287
330
|
describe("reader", () => {
|
|
288
331
|
let accessRulesReader: AccessRulesReader;
|
package/src/transformRule.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { Address4, Address6 } from "ip-address";
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import type { AccessRule } from "./rule.js";
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
accessPolicyInputShape,
|
|
22
22
|
policyScopeInput,
|
|
23
23
|
} from "./ruleInput/policyInput.js";
|
|
24
24
|
import { accessRuleInput } from "./ruleInput/ruleInput.js";
|
|
@@ -56,7 +56,7 @@ export const transformAccessRuleIntoRecord = (
|
|
|
56
56
|
|
|
57
57
|
const accessRuleToRecordScheme = z
|
|
58
58
|
.object({
|
|
59
|
-
...
|
|
59
|
+
...accessPolicyInputShape.shape,
|
|
60
60
|
...policyScopeInput.shape,
|
|
61
61
|
...userScopeSchema.shape,
|
|
62
62
|
groupId: z.coerce.string().optional(),
|