@intflows/genkit-guard 0.0.11 → 0.0.12
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/README.md +9 -1
- package/dist/pii/storage.d.ts +6 -0
- package/dist/pii/storage.js +67 -10
- package/package.json +1 -1
- package/scripts/test-storage.js +137 -78
package/README.md
CHANGED
|
@@ -208,6 +208,7 @@ By default, PII is stored in an in-memory vault scoped to a single tokenizer ins
|
|
|
208
208
|
|
|
209
209
|
That generated namespace prevents two concurrent calls from sharing the same visible placeholder names. Vault lookups are isolated by the configured storage scope, so User A and User B can safely produce their own email tokens without cross-resolving each other's PII.
|
|
210
210
|
|
|
211
|
+
For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend. Redis clients can be passed through the built-in helper:
|
|
211
212
|
For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend. Redis clients can be passed through the built-in helper:
|
|
212
213
|
|
|
213
214
|
```ts
|
|
@@ -223,7 +224,8 @@ guard({
|
|
|
223
224
|
vault: {
|
|
224
225
|
storage: createRedisPiiVaultStorage(redis, {
|
|
225
226
|
keyPrefix: "my-app:pii",
|
|
226
|
-
ttlSeconds: 3600
|
|
227
|
+
ttlSeconds: 3600,
|
|
228
|
+
fallbackToMemory: true
|
|
227
229
|
}),
|
|
228
230
|
scopeId: (req, ctx) => ctx?.auth?.sessionId ?? req?.metadata?.requestId
|
|
229
231
|
}
|
|
@@ -231,6 +233,12 @@ guard({
|
|
|
231
233
|
});
|
|
232
234
|
```
|
|
233
235
|
|
|
236
|
+
`ttlSeconds` applies the configured expiry to both the scoped vault and token index. When
|
|
237
|
+
`fallbackToMemory` is enabled, successful writes are also mirrored in process memory and Redis
|
|
238
|
+
operation failures fall back to that mirror. The fallback is disabled by default, is local to one
|
|
239
|
+
process, and is not a replacement for Redis persistence or multi-worker availability. Its in-memory
|
|
240
|
+
entries observe the same TTL. Redis errors continue to propagate when fallback is disabled.
|
|
241
|
+
|
|
234
242
|
For another backend, use `createPiiVaultStorage({ get, set, entries, getByToken })` with your database, cache, or secret store.
|
|
235
243
|
|
|
236
244
|
Choose a `scopeId` that matches your isolation boundary, such as request ID, session ID, tenant/user ID, or a combination like `tenantId:userId:requestId`. A shared external backend should never ignore `scopeId`, because placeholders are only safe when resolved against the correct vault scope. The placeholder sent to the model uses an opaque generated namespace rather than exposing your `scopeId`.
|
package/dist/pii/storage.d.ts
CHANGED
|
@@ -27,7 +27,13 @@ export type RedisPiiVaultClient = {
|
|
|
27
27
|
export type RedisPiiVaultStorageOptions = {
|
|
28
28
|
keyPrefix?: string;
|
|
29
29
|
tokenIndexKey?: string;
|
|
30
|
+
/** Expire Redis vault keys after this many seconds. Omit to keep them indefinitely. */
|
|
30
31
|
ttlSeconds?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Keep a process-local mirror and use it when a Redis operation fails.
|
|
34
|
+
* Disabled by default so Redis failures remain visible to callers.
|
|
35
|
+
*/
|
|
36
|
+
fallbackToMemory?: boolean;
|
|
31
37
|
};
|
|
32
38
|
export declare function createRedisPiiVaultStorage(redis: RedisPiiVaultClient, options?: RedisPiiVaultStorageOptions): PiiVaultStorage;
|
|
33
39
|
export declare class InMemoryPiiVaultStorage implements PiiVaultStorage {
|
package/dist/pii/storage.js
CHANGED
|
@@ -4,6 +4,13 @@ export function createPiiVaultStorage(adapter) {
|
|
|
4
4
|
export function createRedisPiiVaultStorage(redis, options = {}) {
|
|
5
5
|
const keyPrefix = options.keyPrefix ?? 'genkit-guard:pii';
|
|
6
6
|
const tokenIndexKey = options.tokenIndexKey ?? `${keyPrefix}:tokens`;
|
|
7
|
+
const ttlSeconds = options.ttlSeconds;
|
|
8
|
+
if (ttlSeconds !== undefined && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
|
|
9
|
+
throw new Error('Redis PII vault ttlSeconds must be a positive integer.');
|
|
10
|
+
}
|
|
11
|
+
if (ttlSeconds !== undefined && !redis.expire) {
|
|
12
|
+
throw new Error('Redis PII vault ttlSeconds requires an expire method on the Redis client.');
|
|
13
|
+
}
|
|
7
14
|
const hGet = redis.hGet?.bind(redis) ?? redis.hget?.bind(redis);
|
|
8
15
|
const hSet = redis.hSet?.bind(redis) ?? redis.hset?.bind(redis);
|
|
9
16
|
const hGetAll = redis.hGetAll?.bind(redis) ?? redis.hgetall?.bind(redis);
|
|
@@ -11,31 +18,81 @@ export function createRedisPiiVaultStorage(redis, options = {}) {
|
|
|
11
18
|
throw new Error('Redis PII vault storage requires hGet/hSet/hGetAll or hget/hset/hgetall methods.');
|
|
12
19
|
}
|
|
13
20
|
const scopeKey = (scopeId) => `${keyPrefix}:scope:${scopeId}`;
|
|
21
|
+
const fallback = options.fallbackToMemory ? new ExpiringInMemoryPiiVaultStorage(ttlSeconds) : undefined;
|
|
14
22
|
async function maybeExpire(key) {
|
|
15
|
-
if (
|
|
16
|
-
await redis.expire(key,
|
|
23
|
+
if (ttlSeconds !== undefined) {
|
|
24
|
+
await redis.expire(key, ttlSeconds);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function withFallback(redisOperation, memoryOperation) {
|
|
28
|
+
try {
|
|
29
|
+
return await redisOperation();
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!fallback)
|
|
33
|
+
throw error;
|
|
34
|
+
return memoryOperation();
|
|
17
35
|
}
|
|
18
36
|
}
|
|
19
37
|
return createPiiVaultStorage({
|
|
20
38
|
async get(scopeId, token) {
|
|
21
|
-
return (await hGet(scopeKey(scopeId), token)) ?? undefined;
|
|
39
|
+
return withFallback(async () => (await hGet(scopeKey(scopeId), token)) ?? undefined, () => fallback.get(scopeId, token));
|
|
22
40
|
},
|
|
23
41
|
async getByToken(token) {
|
|
24
|
-
return (await hGet(tokenIndexKey, token)) ?? undefined;
|
|
42
|
+
return withFallback(async () => (await hGet(tokenIndexKey, token)) ?? undefined, () => fallback.getByToken(token));
|
|
25
43
|
},
|
|
26
44
|
async set(scopeId, token, value) {
|
|
27
45
|
const scopedKey = scopeKey(scopeId);
|
|
28
|
-
|
|
29
|
-
await
|
|
30
|
-
await
|
|
31
|
-
|
|
46
|
+
// Warm the opt-in fallback on every write so data written before an outage is available.
|
|
47
|
+
await fallback?.set(scopeId, token, value);
|
|
48
|
+
await withFallback(async () => {
|
|
49
|
+
await hSet(scopedKey, token, value);
|
|
50
|
+
await hSet(tokenIndexKey, token, value);
|
|
51
|
+
await maybeExpire(scopedKey);
|
|
52
|
+
await maybeExpire(tokenIndexKey);
|
|
53
|
+
}, () => undefined);
|
|
32
54
|
},
|
|
33
55
|
async entries(scopeId) {
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
return withFallback(async () => {
|
|
57
|
+
const values = await hGetAll(scopeKey(scopeId));
|
|
58
|
+
return Object.entries(values).map(([token, value]) => ({ token, value }));
|
|
59
|
+
}, () => fallback.entries(scopeId));
|
|
36
60
|
},
|
|
37
61
|
});
|
|
38
62
|
}
|
|
63
|
+
class ExpiringInMemoryPiiVaultStorage {
|
|
64
|
+
ttlSeconds;
|
|
65
|
+
storage = new InMemoryPiiVaultStorage();
|
|
66
|
+
scopeExpiresAt = new Map();
|
|
67
|
+
tokenIndexExpiresAt;
|
|
68
|
+
constructor(ttlSeconds) {
|
|
69
|
+
this.ttlSeconds = ttlSeconds;
|
|
70
|
+
}
|
|
71
|
+
get(scopeId, token) {
|
|
72
|
+
return this.isScopeExpired(scopeId) ? undefined : this.storage.get(scopeId, token);
|
|
73
|
+
}
|
|
74
|
+
getByToken(token) {
|
|
75
|
+
return this.isTokenIndexExpired() ? undefined : this.storage.getByToken(token);
|
|
76
|
+
}
|
|
77
|
+
set(scopeId, token, value) {
|
|
78
|
+
this.storage.set(scopeId, token, value);
|
|
79
|
+
if (this.ttlSeconds !== undefined) {
|
|
80
|
+
const expiry = Date.now() + this.ttlSeconds * 1_000;
|
|
81
|
+
this.scopeExpiresAt.set(scopeId, expiry);
|
|
82
|
+
this.tokenIndexExpiresAt = expiry;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
entries(scopeId) {
|
|
86
|
+
return this.isScopeExpired(scopeId) ? [] : this.storage.entries(scopeId);
|
|
87
|
+
}
|
|
88
|
+
isScopeExpired(scopeId) {
|
|
89
|
+
const expiry = this.scopeExpiresAt.get(scopeId);
|
|
90
|
+
return expiry !== undefined && expiry <= Date.now();
|
|
91
|
+
}
|
|
92
|
+
isTokenIndexExpired() {
|
|
93
|
+
return this.tokenIndexExpiresAt !== undefined && this.tokenIndexExpiresAt <= Date.now();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
39
96
|
export class InMemoryPiiVaultStorage {
|
|
40
97
|
scopes = new Map();
|
|
41
98
|
tokenIndex = new Map();
|
package/package.json
CHANGED
package/scripts/test-storage.js
CHANGED
|
@@ -1,78 +1,137 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
3
|
-
createPiiVaultStorage,
|
|
4
|
-
createRedisPiiVaultStorage,
|
|
5
|
-
InMemoryPiiVaultStorage,
|
|
6
|
-
} from '../dist/index.js';
|
|
7
|
-
import { PiiTokenizer } from '../dist/pii/tokenizer.js';
|
|
8
|
-
|
|
9
|
-
class FakeRedis {
|
|
10
|
-
hashes = new Map();
|
|
11
|
-
expirations = new Map();
|
|
12
|
-
|
|
13
|
-
async hGet(key, field) {
|
|
14
|
-
return this.hashes.get(key)?.[field] ?? null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async hSet(key, field, value) {
|
|
18
|
-
const hash = this.hashes.get(key) ?? {};
|
|
19
|
-
hash[field] = value;
|
|
20
|
-
this.hashes.set(key, hash);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async hGetAll(key) {
|
|
24
|
-
return this.hashes.get(key) ?? {};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async expire(key, seconds) {
|
|
28
|
-
this.expirations.set(key, seconds);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const memory = new InMemoryPiiVaultStorage();
|
|
33
|
-
await memory.set('scope-a', '[[EMAIL_token_0]]', 'a@example.com');
|
|
34
|
-
assert.equal(await memory.get('scope-a', '[[EMAIL_token_0]]'), 'a@example.com');
|
|
35
|
-
assert.equal(await memory.get('scope-b', '[[EMAIL_token_0]]'), undefined);
|
|
36
|
-
assert.equal(await memory.getByToken('[[EMAIL_token_0]]'), 'a@example.com');
|
|
37
|
-
|
|
38
|
-
const custom = createPiiVaultStorage({
|
|
39
|
-
async get(scopeId, token) {
|
|
40
|
-
return scopeId === 'custom' && token === '[[EMAIL_custom_0]]' ? 'custom@example.com' : undefined;
|
|
41
|
-
},
|
|
42
|
-
async set() {},
|
|
43
|
-
async entries(scopeId) {
|
|
44
|
-
return scopeId === 'custom'
|
|
45
|
-
? [{ token: '[[EMAIL_custom_0]]', value: 'custom@example.com' }]
|
|
46
|
-
: [];
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const customTokenizer = new PiiTokenizer({ scopeId: 'custom', storage: custom });
|
|
51
|
-
assert.equal(await customTokenizer.unmask('Hi [[EMAIL_custom_0]]'), 'Hi custom@example.com');
|
|
52
|
-
|
|
53
|
-
const redis = new FakeRedis();
|
|
54
|
-
const redisStorage = createRedisPiiVaultStorage(redis, {
|
|
55
|
-
keyPrefix: 'test:pii',
|
|
56
|
-
ttlSeconds: 60,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const userATokenizer = new PiiTokenizer({ scopeId: 'tenant:userA', storage: redisStorage });
|
|
60
|
-
const userBTokenizer = new PiiTokenizer({ scopeId: 'tenant:userB', storage: redisStorage });
|
|
61
|
-
|
|
62
|
-
const userAMasked = await userATokenizer.mask('Email alice@example.com', [
|
|
63
|
-
{ type: 'EMAIL', value: 'alice@example.com' },
|
|
64
|
-
]);
|
|
65
|
-
const userBMasked = await userBTokenizer.mask('Email bob@example.com', [
|
|
66
|
-
{ type: 'EMAIL', value: 'bob@example.com' },
|
|
67
|
-
]);
|
|
68
|
-
|
|
69
|
-
assert.notEqual(userAMasked.maskedText, userBMasked.maskedText);
|
|
70
|
-
assert.equal(await userATokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
71
|
-
assert.equal(await userBTokenizer.unmask(userBMasked.maskedText), 'Email bob@example.com');
|
|
72
|
-
assert.equal(await userATokenizer.unmask(userBMasked.maskedText), userBMasked.maskedText);
|
|
73
|
-
|
|
74
|
-
const laterPassTokenizer = new PiiTokenizer({ scopeId: 'tenant:userA:later', storage: redisStorage });
|
|
75
|
-
await laterPassTokenizer.importTokens(userAMasked.maskedText);
|
|
76
|
-
assert.equal(await laterPassTokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
77
|
-
|
|
78
|
-
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
createPiiVaultStorage,
|
|
4
|
+
createRedisPiiVaultStorage,
|
|
5
|
+
InMemoryPiiVaultStorage,
|
|
6
|
+
} from '../dist/index.js';
|
|
7
|
+
import { PiiTokenizer } from '../dist/pii/tokenizer.js';
|
|
8
|
+
|
|
9
|
+
class FakeRedis {
|
|
10
|
+
hashes = new Map();
|
|
11
|
+
expirations = new Map();
|
|
12
|
+
|
|
13
|
+
async hGet(key, field) {
|
|
14
|
+
return this.hashes.get(key)?.[field] ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async hSet(key, field, value) {
|
|
18
|
+
const hash = this.hashes.get(key) ?? {};
|
|
19
|
+
hash[field] = value;
|
|
20
|
+
this.hashes.set(key, hash);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async hGetAll(key) {
|
|
24
|
+
return this.hashes.get(key) ?? {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async expire(key, seconds) {
|
|
28
|
+
this.expirations.set(key, seconds);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const memory = new InMemoryPiiVaultStorage();
|
|
33
|
+
await memory.set('scope-a', '[[EMAIL_token_0]]', 'a@example.com');
|
|
34
|
+
assert.equal(await memory.get('scope-a', '[[EMAIL_token_0]]'), 'a@example.com');
|
|
35
|
+
assert.equal(await memory.get('scope-b', '[[EMAIL_token_0]]'), undefined);
|
|
36
|
+
assert.equal(await memory.getByToken('[[EMAIL_token_0]]'), 'a@example.com');
|
|
37
|
+
|
|
38
|
+
const custom = createPiiVaultStorage({
|
|
39
|
+
async get(scopeId, token) {
|
|
40
|
+
return scopeId === 'custom' && token === '[[EMAIL_custom_0]]' ? 'custom@example.com' : undefined;
|
|
41
|
+
},
|
|
42
|
+
async set() {},
|
|
43
|
+
async entries(scopeId) {
|
|
44
|
+
return scopeId === 'custom'
|
|
45
|
+
? [{ token: '[[EMAIL_custom_0]]', value: 'custom@example.com' }]
|
|
46
|
+
: [];
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const customTokenizer = new PiiTokenizer({ scopeId: 'custom', storage: custom });
|
|
51
|
+
assert.equal(await customTokenizer.unmask('Hi [[EMAIL_custom_0]]'), 'Hi custom@example.com');
|
|
52
|
+
|
|
53
|
+
const redis = new FakeRedis();
|
|
54
|
+
const redisStorage = createRedisPiiVaultStorage(redis, {
|
|
55
|
+
keyPrefix: 'test:pii',
|
|
56
|
+
ttlSeconds: 60,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const userATokenizer = new PiiTokenizer({ scopeId: 'tenant:userA', storage: redisStorage });
|
|
60
|
+
const userBTokenizer = new PiiTokenizer({ scopeId: 'tenant:userB', storage: redisStorage });
|
|
61
|
+
|
|
62
|
+
const userAMasked = await userATokenizer.mask('Email alice@example.com', [
|
|
63
|
+
{ type: 'EMAIL', value: 'alice@example.com' },
|
|
64
|
+
]);
|
|
65
|
+
const userBMasked = await userBTokenizer.mask('Email bob@example.com', [
|
|
66
|
+
{ type: 'EMAIL', value: 'bob@example.com' },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
assert.notEqual(userAMasked.maskedText, userBMasked.maskedText);
|
|
70
|
+
assert.equal(await userATokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
71
|
+
assert.equal(await userBTokenizer.unmask(userBMasked.maskedText), 'Email bob@example.com');
|
|
72
|
+
assert.equal(await userATokenizer.unmask(userBMasked.maskedText), userBMasked.maskedText);
|
|
73
|
+
|
|
74
|
+
const laterPassTokenizer = new PiiTokenizer({ scopeId: 'tenant:userA:later', storage: redisStorage });
|
|
75
|
+
await laterPassTokenizer.importTokens(userAMasked.maskedText);
|
|
76
|
+
assert.equal(await laterPassTokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
|
|
77
|
+
|
|
78
|
+
assert.equal(redis.expirations.get('test:pii:scope:tenant:userA'), 60);
|
|
79
|
+
assert.equal(redis.expirations.get('test:pii:tokens'), 60);
|
|
80
|
+
|
|
81
|
+
class FailingRedis extends FakeRedis {
|
|
82
|
+
failed = false;
|
|
83
|
+
|
|
84
|
+
fail() {
|
|
85
|
+
this.failed = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async hGet(key, field) {
|
|
89
|
+
if (this.failed) throw new Error('Redis unavailable');
|
|
90
|
+
return super.hGet(key, field);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async hSet(key, field, value) {
|
|
94
|
+
if (this.failed) throw new Error('Redis unavailable');
|
|
95
|
+
return super.hSet(key, field, value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async hGetAll(key) {
|
|
99
|
+
if (this.failed) throw new Error('Redis unavailable');
|
|
100
|
+
return super.hGetAll(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const failingRedis = new FailingRedis();
|
|
105
|
+
const resilientStorage = createRedisPiiVaultStorage(failingRedis, { fallbackToMemory: true });
|
|
106
|
+
await resilientStorage.set('resilient-scope', '[[EMAIL_resilient_0]]', 'safe@example.com');
|
|
107
|
+
failingRedis.fail();
|
|
108
|
+
assert.equal(
|
|
109
|
+
await resilientStorage.get('resilient-scope', '[[EMAIL_resilient_0]]'),
|
|
110
|
+
'safe@example.com'
|
|
111
|
+
);
|
|
112
|
+
assert.equal(await resilientStorage.getByToken('[[EMAIL_resilient_0]]'), 'safe@example.com');
|
|
113
|
+
assert.deepEqual(await resilientStorage.entries('resilient-scope'), [
|
|
114
|
+
{ token: '[[EMAIL_resilient_0]]', value: 'safe@example.com' },
|
|
115
|
+
]);
|
|
116
|
+
await resilientStorage.set('resilient-scope', '[[PHONE_resilient_1]]', '0400000000');
|
|
117
|
+
assert.equal(await resilientStorage.getByToken('[[PHONE_resilient_1]]'), '0400000000');
|
|
118
|
+
|
|
119
|
+
const strictRedis = new FailingRedis();
|
|
120
|
+
const strictStorage = createRedisPiiVaultStorage(strictRedis);
|
|
121
|
+
strictRedis.fail();
|
|
122
|
+
await assert.rejects(() => strictStorage.get('scope', 'token'), /Redis unavailable/);
|
|
123
|
+
|
|
124
|
+
assert.throws(
|
|
125
|
+
() => createRedisPiiVaultStorage(new FakeRedis(), { ttlSeconds: 0 }),
|
|
126
|
+
/positive integer/
|
|
127
|
+
);
|
|
128
|
+
assert.throws(
|
|
129
|
+
() =>
|
|
130
|
+
createRedisPiiVaultStorage(
|
|
131
|
+
{ hGet: async () => null, hSet: async () => undefined, hGetAll: async () => ({}) },
|
|
132
|
+
{ ttlSeconds: 60 }
|
|
133
|
+
),
|
|
134
|
+
/requires an expire method/
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
console.log('PII vault storage tests passed.');
|