@open-mercato/shared 0.8.1-develop.7215.1.60786cf836 → 0.8.1-develop.7218.1.9f22882799
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/dist/lib/encryption/customFieldValues.js +47 -2
- package/dist/lib/encryption/customFieldValues.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/encryption/__tests__/customFieldValues.plaintext-fallback.test.ts +208 -0
- package/src/lib/encryption/customFieldValues.ts +97 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createLogger } from "../logger/index.js";
|
|
1
2
|
import { encryptWithAesGcm, decryptWithAesGcm } from "./aes.js";
|
|
2
3
|
import { TenantDataEncryptionService } from "./tenantDataEncryptionService.js";
|
|
4
|
+
import { isTenantDataEncryptionEnabled } from "./toggles.js";
|
|
5
|
+
const logger = createLogger("shared").child({ component: "encryption" });
|
|
3
6
|
const STRING_TYPED_CUSTOM_FIELD_KINDS = /* @__PURE__ */ new Set([
|
|
4
7
|
"text",
|
|
5
8
|
"multiline",
|
|
@@ -37,10 +40,51 @@ async function resolveDekKey(service, tenantId, cache, opts) {
|
|
|
37
40
|
cache?.set(scopedTenantId, key);
|
|
38
41
|
return key;
|
|
39
42
|
}
|
|
40
|
-
|
|
43
|
+
function isEncryptionExpected(service, tenantId) {
|
|
44
|
+
if (!service || !(tenantId ?? null)) return false;
|
|
45
|
+
return isTenantDataEncryptionEnabled();
|
|
46
|
+
}
|
|
47
|
+
const PLAINTEXT_FALLBACK_WARN_CAP = 5e3;
|
|
48
|
+
const plaintextFallbackWarned = /* @__PURE__ */ new Set();
|
|
49
|
+
function resetEncryptedFieldPlaintextFallbackWarnCache() {
|
|
50
|
+
plaintextFallbackWarned.clear();
|
|
51
|
+
}
|
|
52
|
+
function clearPlaintextFallbackWarnings(tenantId) {
|
|
53
|
+
if (!plaintextFallbackWarned.size) return;
|
|
54
|
+
const prefix = `${tenantId ?? null}|`;
|
|
55
|
+
for (const warnKey of plaintextFallbackWarned) {
|
|
56
|
+
if (warnKey.startsWith(prefix)) plaintextFallbackWarned.delete(warnKey);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function warnOnPlaintextFallback(tenantId, options) {
|
|
60
|
+
try {
|
|
61
|
+
const scopedTenantId = tenantId ?? null;
|
|
62
|
+
const entity = options?.entityId ?? null;
|
|
63
|
+
const field = options?.fieldKey ?? null;
|
|
64
|
+
const warnKey = `${scopedTenantId}|${entity ?? "unknown"}|${field ?? "unknown"}`;
|
|
65
|
+
if (plaintextFallbackWarned.has(warnKey)) return;
|
|
66
|
+
if (plaintextFallbackWarned.size >= PLAINTEXT_FALLBACK_WARN_CAP) {
|
|
67
|
+
const oldest = plaintextFallbackWarned.values().next().value;
|
|
68
|
+
if (oldest !== void 0) plaintextFallbackWarned.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
plaintextFallbackWarned.add(warnKey);
|
|
71
|
+
logger.warn("Custom field configured as encrypted was stored as plaintext", {
|
|
72
|
+
tenantId: scopedTenantId,
|
|
73
|
+
entity,
|
|
74
|
+
field,
|
|
75
|
+
hint: "The tenant data encryption key could not be read or created (KMS/Vault unavailable, or DEK creation failed), so the value was written unencrypted. Restore key access and re-save the affected records."
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function encryptCustomFieldValue(value, tenantId, service, cache, options) {
|
|
41
81
|
if (value === void 0 || value === null) return value;
|
|
42
82
|
const key = await resolveDekKey(service, tenantId, cache, { createIfMissing: true });
|
|
43
|
-
if (!key)
|
|
83
|
+
if (!key) {
|
|
84
|
+
if (isEncryptionExpected(service, tenantId)) warnOnPlaintextFallback(tenantId, options);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
clearPlaintextFallbackWarnings(tenantId);
|
|
44
88
|
const serialized = typeof value === "string" ? value : JSON.stringify(value);
|
|
45
89
|
return encryptWithAesGcm(serialized, key).value;
|
|
46
90
|
}
|
|
@@ -60,6 +104,7 @@ async function decryptCustomFieldValue(value, tenantId, service, cache, options)
|
|
|
60
104
|
export {
|
|
61
105
|
decryptCustomFieldValue,
|
|
62
106
|
encryptCustomFieldValue,
|
|
107
|
+
resetEncryptedFieldPlaintextFallbackWarnCache,
|
|
63
108
|
resolveTenantEncryptionService
|
|
64
109
|
};
|
|
65
110
|
//# sourceMappingURL=customFieldValues.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/customFieldValues.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { encryptWithAesGcm, decryptWithAesGcm } from './aes'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\n\n/**\n * Custom field kinds that ALWAYS round-trip as a string. The encrypt path\n * stores raw strings unwrapped, so blindly running `JSON.parse` on the\n * decrypted payload coerces text values like `\"123\"` or `\"true\"` back into\n * numbers/booleans (issue #1734). For these kinds, callers MUST pass the\n * `kind` option so we keep the decrypted value as a string.\n *\n * Numeric (`integer`/`float`) and `boolean` kinds rely on JSON round-trip\n * because the encrypt path JSON-stringifies the typed value before storage.\n * Omitting the kind preserves legacy round-trip behavior for backward\n * compatibility.\n */\nconst STRING_TYPED_CUSTOM_FIELD_KINDS = new Set([\n 'text',\n 'multiline',\n 'select',\n 'currency',\n 'dictionary',\n 'phone',\n 'email',\n 'url',\n 'string',\n])\n\nexport type DecryptCustomFieldOptions = {\n /** Field kind, e.g. from `CustomFieldDef.kind`. When string-typed, the helper preserves the decrypted string verbatim. */\n kind?: string | null\n}\n\nfunction shouldPreserveAsString(kind: string | null | undefined): boolean {\n if (!kind) return false\n return STRING_TYPED_CUSTOM_FIELD_KINDS.has(kind)\n}\n\nconst serviceCache = new WeakMap<EntityManager, TenantDataEncryptionService>()\n\nexport function resolveTenantEncryptionService(\n em: EntityManager,\n provided?: TenantDataEncryptionService | null,\n): TenantDataEncryptionService | null {\n if (provided) return provided\n const cached = serviceCache.get(em)\n if (cached) return cached\n const service = new TenantDataEncryptionService(em as any)\n serviceCache.set(em, service)\n return service\n}\n\nasync function resolveDekKey(\n service: TenantDataEncryptionService | null,\n tenantId: string | null | undefined,\n cache?: Map<string | null, string | null>,\n opts?: { createIfMissing?: boolean },\n): Promise<string | null> {\n const scopedTenantId = tenantId ?? null\n if (!service || !service.isEnabled() || !scopedTenantId) return null\n if (cache?.has(scopedTenantId)) return cache.get(scopedTenantId) ?? null\n const dek = await service.getDek(scopedTenantId)\n let key = dek?.key ?? null\n if (!key && opts?.createIfMissing && typeof service.createDek === 'function') {\n const created = await service.createDek(scopedTenantId)\n key = created?.key ?? null\n }\n cache?.set(scopedTenantId, key)\n return key\n}\n\nexport async function encryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n): Promise<unknown> {\n if (value === undefined || value === null) return value\n const key = await resolveDekKey(service, tenantId, cache, { createIfMissing: true })\n if (!key) return value\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n return encryptWithAesGcm(serialized, key).value\n}\n\nexport async function decryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n options?: DecryptCustomFieldOptions,\n): Promise<unknown> {\n if (value === undefined || value === null || typeof value !== 'string') return value\n const key = await resolveDekKey(service, tenantId, cache)\n if (!key) return value\n const decrypted = decryptWithAesGcm(value, key)\n if (decrypted === null) return value\n if (shouldPreserveAsString(options?.kind ?? null)) return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,mBAAmB,yBAAyB;AACrD,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { createLogger } from '../logger'\nimport { encryptWithAesGcm, decryptWithAesGcm } from './aes'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\nimport { isTenantDataEncryptionEnabled } from './toggles'\n\nconst logger = createLogger('shared').child({ component: 'encryption' })\n\n/**\n * Custom field kinds that ALWAYS round-trip as a string. The encrypt path\n * stores raw strings unwrapped, so blindly running `JSON.parse` on the\n * decrypted payload coerces text values like `\"123\"` or `\"true\"` back into\n * numbers/booleans (issue #1734). For these kinds, callers MUST pass the\n * `kind` option so we keep the decrypted value as a string.\n *\n * Numeric (`integer`/`float`) and `boolean` kinds rely on JSON round-trip\n * because the encrypt path JSON-stringifies the typed value before storage.\n * Omitting the kind preserves legacy round-trip behavior for backward\n * compatibility.\n */\nconst STRING_TYPED_CUSTOM_FIELD_KINDS = new Set([\n 'text',\n 'multiline',\n 'select',\n 'currency',\n 'dictionary',\n 'phone',\n 'email',\n 'url',\n 'string',\n])\n\nexport type DecryptCustomFieldOptions = {\n /** Field kind, e.g. from `CustomFieldDef.kind`. When string-typed, the helper preserves the decrypted string verbatim. */\n kind?: string | null\n}\n\nfunction shouldPreserveAsString(kind: string | null | undefined): boolean {\n if (!kind) return false\n return STRING_TYPED_CUSTOM_FIELD_KINDS.has(kind)\n}\n\nconst serviceCache = new WeakMap<EntityManager, TenantDataEncryptionService>()\n\nexport function resolveTenantEncryptionService(\n em: EntityManager,\n provided?: TenantDataEncryptionService | null,\n): TenantDataEncryptionService | null {\n if (provided) return provided\n const cached = serviceCache.get(em)\n if (cached) return cached\n const service = new TenantDataEncryptionService(em as any)\n serviceCache.set(em, service)\n return service\n}\n\nasync function resolveDekKey(\n service: TenantDataEncryptionService | null,\n tenantId: string | null | undefined,\n cache?: Map<string | null, string | null>,\n opts?: { createIfMissing?: boolean },\n): Promise<string | null> {\n const scopedTenantId = tenantId ?? null\n if (!service || !service.isEnabled() || !scopedTenantId) return null\n if (cache?.has(scopedTenantId)) return cache.get(scopedTenantId) ?? null\n const dek = await service.getDek(scopedTenantId)\n let key = dek?.key ?? null\n if (!key && opts?.createIfMissing && typeof service.createDek === 'function') {\n const created = await service.createDek(scopedTenantId)\n key = created?.key ?? null\n }\n cache?.set(scopedTenantId, key)\n return key\n}\n\n/**\n * Whether the caller asked for a write that is supposed to end up encrypted.\n *\n * `resolveDekKey` returns `null` for several situations. Two are intentional\n * no-ops \u2014 no encryption service is wired, or the record has no tenant scope \u2014\n * and one is the operator deliberately running unencrypted\n * (`TENANT_DATA_ENCRYPTION=no`). Anything else means the caller asked for an\n * encrypted write that could not be performed, which is worth reporting.\n *\n * This deliberately checks the `TENANT_DATA_ENCRYPTION` env toggle rather than\n * `service.isEnabled()`. `isEnabled()` folds the toggle together with KMS\n * health, and an unreachable Vault with no fallback secret resolves to\n * `NoopKmsService`, whose `isHealthy()` is false whenever encryption is on \u2014 so\n * gating on it would stay silent during exactly the outage this warning exists\n * to surface. The env toggle alone expresses the operator's intent.\n */\nfunction isEncryptionExpected(\n service: TenantDataEncryptionService | null,\n tenantId: string | null | undefined,\n): boolean {\n if (!service || !(tenantId ?? null)) return false\n return isTenantDataEncryptionEnabled()\n}\n\n// One warning per tenant/entity/field per OUTAGE. A key-store outage makes this\n// branch run for every field of every write, so the warning is throttled \u2014 but\n// the entries for a tenant are dropped again as soon as one of its writes\n// encrypts successfully. Throttling for the lifetime of the process instead\n// would report the first outage and silently swallow every later one, which is\n// the failure this warning exists to make visible.\nconst PLAINTEXT_FALLBACK_WARN_CAP = 5000\nconst plaintextFallbackWarned = new Set<string>()\n\n/** Test seam: the warn-once cache is process-global by design. */\nexport function resetEncryptedFieldPlaintextFallbackWarnCache(): void {\n plaintextFallbackWarned.clear()\n}\n\n/**\n * Forget a tenant's plaintext-fallback warnings once its key resolves again, so\n * a later outage is reported instead of being throttled away by the previous\n * one. The `size` guard keeps the healthy path \u2014 an empty set \u2014 at O(1).\n */\nfunction clearPlaintextFallbackWarnings(tenantId: string | null | undefined): void {\n if (!plaintextFallbackWarned.size) return\n const prefix = `${tenantId ?? null}|`\n for (const warnKey of plaintextFallbackWarned) {\n if (warnKey.startsWith(prefix)) plaintextFallbackWarned.delete(warnKey)\n }\n}\n\nfunction warnOnPlaintextFallback(\n tenantId: string | null | undefined,\n options?: EncryptCustomFieldOptions,\n): void {\n try {\n const scopedTenantId = tenantId ?? null\n const entity = options?.entityId ?? null\n const field = options?.fieldKey ?? null\n const warnKey = `${scopedTenantId}|${entity ?? 'unknown'}|${field ?? 'unknown'}`\n if (plaintextFallbackWarned.has(warnKey)) return\n if (plaintextFallbackWarned.size >= PLAINTEXT_FALLBACK_WARN_CAP) {\n const oldest = plaintextFallbackWarned.values().next().value\n if (oldest !== undefined) plaintextFallbackWarned.delete(oldest)\n }\n plaintextFallbackWarned.add(warnKey)\n logger.warn('Custom field configured as encrypted was stored as plaintext', {\n tenantId: scopedTenantId,\n entity,\n field,\n hint: 'The tenant data encryption key could not be read or created (KMS/Vault unavailable, or DEK creation failed), so the value was written unencrypted. Restore key access and re-save the affected records.',\n })\n } catch {\n // A diagnostic must never break the write it is diagnosing.\n }\n}\n\nexport type EncryptCustomFieldOptions = {\n /** Entity the value belongs to, e.g. `customers:person`. Used only to identify the field in diagnostics. */\n entityId?: string | null\n /** Custom field key, e.g. from `CustomFieldDef.key`. Used only to identify the field in diagnostics. */\n fieldKey?: string | null\n}\n\nexport async function encryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n options?: EncryptCustomFieldOptions,\n): Promise<unknown> {\n if (value === undefined || value === null) return value\n const key = await resolveDekKey(service, tenantId, cache, { createIfMissing: true })\n if (!key) {\n // Key resolution failed for a field the operator configured as encrypted.\n // The write still goes through as plaintext (failing it would drop data on\n // a transient outage), but it must not be silent \u2014 issue #5921.\n if (isEncryptionExpected(service, tenantId)) warnOnPlaintextFallback(tenantId, options)\n return value\n }\n clearPlaintextFallbackWarnings(tenantId)\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n return encryptWithAesGcm(serialized, key).value\n}\n\nexport async function decryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n options?: DecryptCustomFieldOptions,\n): Promise<unknown> {\n if (value === undefined || value === null || typeof value !== 'string') return value\n const key = await resolveDekKey(service, tenantId, cache)\n if (!key) return value\n const decrypted = decryptWithAesGcm(value, key)\n if (decrypted === null) return value\n if (shouldPreserveAsString(options?.kind ?? null)) return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7218.1.9f22882799';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7218.1.9f22882799",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@mikro-orm/core": "^7.1.14",
|
|
114
114
|
"@mikro-orm/decorators": "^7.1.14",
|
|
115
115
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
116
|
-
"@open-mercato/cache": "0.8.1-develop.
|
|
116
|
+
"@open-mercato/cache": "0.8.1-develop.7218.1.9f22882799",
|
|
117
117
|
"@types/html-to-text": "^9.0.4",
|
|
118
118
|
"@types/sanitize-html": "^2.16.1",
|
|
119
119
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encryptCustomFieldValue,
|
|
3
|
+
resetEncryptedFieldPlaintextFallbackWarnCache,
|
|
4
|
+
} from '../customFieldValues'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../logger', () => {
|
|
7
|
+
const warn = jest.fn()
|
|
8
|
+
const debug = jest.fn()
|
|
9
|
+
const info = jest.fn()
|
|
10
|
+
const error = jest.fn()
|
|
11
|
+
const child = jest.fn(() => ({ warn, debug, info, error }))
|
|
12
|
+
return { createLogger: jest.fn(() => ({ child })), __warn: warn }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const loggerModule = jest.requireMock('../../logger') as { __warn: jest.Mock }
|
|
16
|
+
|
|
17
|
+
const WARN_MESSAGE = 'Custom field configured as encrypted was stored as plaintext'
|
|
18
|
+
|
|
19
|
+
const warnCalls = () =>
|
|
20
|
+
loggerModule.__warn.mock.calls.filter(([message]) => message === WARN_MESSAGE)
|
|
21
|
+
|
|
22
|
+
const fixedKey = Buffer.alloc(32, 1).toString('base64')
|
|
23
|
+
|
|
24
|
+
/** Encryption is on and the tenant is scoped, but no DEK can be read or created. */
|
|
25
|
+
function unresolvableDekService(overrides: Record<string, unknown> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
isEnabled: () => true,
|
|
28
|
+
getDek: jest.fn(async () => null),
|
|
29
|
+
createDek: jest.fn(async () => null),
|
|
30
|
+
...overrides,
|
|
31
|
+
} as any
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('encryptCustomFieldValue plaintext fallback (regression: issue #5921)', () => {
|
|
35
|
+
const previousToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
resetEncryptedFieldPlaintextFallbackWarnCache()
|
|
39
|
+
loggerModule.__warn.mockClear()
|
|
40
|
+
delete process.env.TENANT_DATA_ENCRYPTION
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
if (previousToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
45
|
+
else process.env.TENANT_DATA_ENCRYPTION = previousToggle
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('warns when an encrypted field falls back to plaintext because the DEK is unavailable', async () => {
|
|
49
|
+
const service = unresolvableDekService()
|
|
50
|
+
|
|
51
|
+
const stored = await encryptCustomFieldValue('secret', 'tenant-1', service, undefined, {
|
|
52
|
+
entityId: 'customers:person',
|
|
53
|
+
fieldKey: 'national_id',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(stored).toBe('secret')
|
|
57
|
+
expect(service.createDek).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(warnCalls()).toHaveLength(1)
|
|
59
|
+
const [, payload] = warnCalls()[0]
|
|
60
|
+
expect(payload).toMatchObject({
|
|
61
|
+
tenantId: 'tenant-1',
|
|
62
|
+
entity: 'customers:person',
|
|
63
|
+
field: 'national_id',
|
|
64
|
+
})
|
|
65
|
+
expect(typeof payload.hint).toBe('string')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('never puts the field value in the warning payload', async () => {
|
|
69
|
+
await encryptCustomFieldValue('super-secret-value', 'tenant-1', unresolvableDekService(), undefined, {
|
|
70
|
+
entityId: 'customers:person',
|
|
71
|
+
fieldKey: 'national_id',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(JSON.stringify(warnCalls()[0])).not.toContain('super-secret-value')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('still warns when the caller does not identify the field', async () => {
|
|
78
|
+
await encryptCustomFieldValue('secret', 'tenant-1', unresolvableDekService())
|
|
79
|
+
|
|
80
|
+
expect(warnCalls()).toHaveLength(1)
|
|
81
|
+
expect(warnCalls()[0][1]).toMatchObject({ tenantId: 'tenant-1', entity: null, field: null })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// The issue's own reproduction: VAULT_ADDR points at an unreachable Vault and
|
|
85
|
+
// no fallback secret is set, so createKmsService() hands back a NoopKmsService
|
|
86
|
+
// whose isHealthy() is false while TENANT_DATA_ENCRYPTION is on — which makes
|
|
87
|
+
// service.isEnabled() false. Gating the warning on isEnabled() would go silent
|
|
88
|
+
// in exactly this case, so it must be driven by the env toggle instead.
|
|
89
|
+
it('warns when the KMS is unhealthy and the service therefore reports itself disabled', async () => {
|
|
90
|
+
const unhealthyKms = {
|
|
91
|
+
isEnabled: () => false,
|
|
92
|
+
getDek: jest.fn(async () => null),
|
|
93
|
+
createDek: jest.fn(async () => null),
|
|
94
|
+
} as any
|
|
95
|
+
|
|
96
|
+
const stored = await encryptCustomFieldValue('secret', 'tenant-1', unhealthyKms, undefined, {
|
|
97
|
+
entityId: 'customers:person',
|
|
98
|
+
fieldKey: 'national_id',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(stored).toBe('secret')
|
|
102
|
+
expect(warnCalls()).toHaveLength(1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('stays silent for the intentional plaintext cases', async () => {
|
|
106
|
+
// Operator deliberately runs unencrypted, so a plaintext write is correct.
|
|
107
|
+
process.env.TENANT_DATA_ENCRYPTION = 'no'
|
|
108
|
+
expect(await encryptCustomFieldValue('plain', 'tenant-1', unresolvableDekService())).toBe('plain')
|
|
109
|
+
delete process.env.TENANT_DATA_ENCRYPTION
|
|
110
|
+
|
|
111
|
+
// No tenant scope, so there is no tenant DEK to resolve.
|
|
112
|
+
expect(await encryptCustomFieldValue('plain', null, unresolvableDekService())).toBe('plain')
|
|
113
|
+
expect(await encryptCustomFieldValue('plain', undefined, unresolvableDekService())).toBe('plain')
|
|
114
|
+
// No encryption service wired at all.
|
|
115
|
+
expect(await encryptCustomFieldValue('plain', 'tenant-1', null)).toBe('plain')
|
|
116
|
+
// Null/undefined values are skipped before key resolution.
|
|
117
|
+
expect(await encryptCustomFieldValue(null, 'tenant-1', unresolvableDekService())).toBe(null)
|
|
118
|
+
|
|
119
|
+
expect(warnCalls()).toHaveLength(0)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('warns once per tenant/entity/field so a key outage cannot flood the log', async () => {
|
|
123
|
+
const service = unresolvableDekService()
|
|
124
|
+
const field = { entityId: 'customers:person', fieldKey: 'national_id' }
|
|
125
|
+
|
|
126
|
+
await encryptCustomFieldValue('a', 'tenant-1', service, undefined, field)
|
|
127
|
+
await encryptCustomFieldValue('b', 'tenant-1', service, undefined, field)
|
|
128
|
+
await encryptCustomFieldValue('c', 'tenant-1', service, undefined, field)
|
|
129
|
+
|
|
130
|
+
expect(warnCalls()).toHaveLength(1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('reports each degraded field and tenant separately', async () => {
|
|
134
|
+
const service = unresolvableDekService()
|
|
135
|
+
|
|
136
|
+
await encryptCustomFieldValue('a', 'tenant-1', service, undefined, {
|
|
137
|
+
entityId: 'customers:person',
|
|
138
|
+
fieldKey: 'national_id',
|
|
139
|
+
})
|
|
140
|
+
await encryptCustomFieldValue('b', 'tenant-1', service, undefined, {
|
|
141
|
+
entityId: 'customers:person',
|
|
142
|
+
fieldKey: 'passport_no',
|
|
143
|
+
})
|
|
144
|
+
await encryptCustomFieldValue('c', 'tenant-2', service, undefined, {
|
|
145
|
+
entityId: 'customers:person',
|
|
146
|
+
fieldKey: 'national_id',
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(warnCalls()).toHaveLength(3)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Throttling for the whole process lifetime would report the first outage and
|
|
153
|
+
// hide every later one — the exact blind spot this warning exists to remove.
|
|
154
|
+
it('reports a second outage after the key has recovered in between', async () => {
|
|
155
|
+
const field = { entityId: 'customers:person', fieldKey: 'national_id' }
|
|
156
|
+
const recovered = { isEnabled: () => true, getDek: async () => ({ key: fixedKey }) } as any
|
|
157
|
+
|
|
158
|
+
await encryptCustomFieldValue('a', 'tenant-1', unresolvableDekService(), undefined, field)
|
|
159
|
+
expect(warnCalls()).toHaveLength(1)
|
|
160
|
+
|
|
161
|
+
const encrypted = await encryptCustomFieldValue('b', 'tenant-1', recovered, undefined, field)
|
|
162
|
+
expect(encrypted).not.toBe('b')
|
|
163
|
+
|
|
164
|
+
await encryptCustomFieldValue('c', 'tenant-1', unresolvableDekService(), undefined, field)
|
|
165
|
+
expect(warnCalls()).toHaveLength(2)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Regression: hitting the cap used to `.clear()` the whole set, un-throttling
|
|
169
|
+
// every already-reported field at once (worst possible time — the log flood
|
|
170
|
+
// the cap exists to prevent). It must evict only the single oldest entry.
|
|
171
|
+
it('evicts only the oldest entry when the throttle cap is reached, not the whole set', async () => {
|
|
172
|
+
const service = unresolvableDekService()
|
|
173
|
+
const WARN_CAP = 5000
|
|
174
|
+
const fieldAt = (i: number) => ({ entityId: 'customers:person', fieldKey: `field-${i}` })
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < WARN_CAP; i += 1) {
|
|
177
|
+
await encryptCustomFieldValue('v', 'tenant-1', service, undefined, fieldAt(i))
|
|
178
|
+
}
|
|
179
|
+
expect(warnCalls()).toHaveLength(WARN_CAP)
|
|
180
|
+
|
|
181
|
+
// One more distinct field crosses the cap and evicts field-0 (the oldest).
|
|
182
|
+
await encryptCustomFieldValue('v', 'tenant-1', service, undefined, fieldAt(WARN_CAP))
|
|
183
|
+
expect(warnCalls()).toHaveLength(WARN_CAP + 1)
|
|
184
|
+
|
|
185
|
+
// A still-degraded field reported well before the cap stays throttled.
|
|
186
|
+
await encryptCustomFieldValue('v', 'tenant-1', service, undefined, fieldAt(WARN_CAP - 1))
|
|
187
|
+
expect(warnCalls()).toHaveLength(WARN_CAP + 1)
|
|
188
|
+
|
|
189
|
+
// The evicted oldest entry is the one exception: it reports again.
|
|
190
|
+
await encryptCustomFieldValue('v', 'tenant-1', service, undefined, fieldAt(0))
|
|
191
|
+
expect(warnCalls()).toHaveLength(WARN_CAP + 2)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not let one tenant recovering unthrottle another tenant still degraded', async () => {
|
|
195
|
+
const field = { entityId: 'customers:person', fieldKey: 'national_id' }
|
|
196
|
+
const recovered = { isEnabled: () => true, getDek: async () => ({ key: fixedKey }) } as any
|
|
197
|
+
|
|
198
|
+
await encryptCustomFieldValue('a', 'tenant-1', unresolvableDekService(), undefined, field)
|
|
199
|
+
await encryptCustomFieldValue('b', 'tenant-2', unresolvableDekService(), undefined, field)
|
|
200
|
+
expect(warnCalls()).toHaveLength(2)
|
|
201
|
+
|
|
202
|
+
await encryptCustomFieldValue('c', 'tenant-1', recovered, undefined, field)
|
|
203
|
+
|
|
204
|
+
// tenant-2 never recovered, so its warning stays throttled.
|
|
205
|
+
await encryptCustomFieldValue('d', 'tenant-2', unresolvableDekService(), undefined, field)
|
|
206
|
+
expect(warnCalls()).toHaveLength(2)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/core'
|
|
2
|
+
import { createLogger } from '../logger'
|
|
2
3
|
import { encryptWithAesGcm, decryptWithAesGcm } from './aes'
|
|
3
4
|
import { TenantDataEncryptionService } from './tenantDataEncryptionService'
|
|
5
|
+
import { isTenantDataEncryptionEnabled } from './toggles'
|
|
6
|
+
|
|
7
|
+
const logger = createLogger('shared').child({ component: 'encryption' })
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Custom field kinds that ALWAYS round-trip as a string. The encrypt path
|
|
@@ -69,15 +73,107 @@ async function resolveDekKey(
|
|
|
69
73
|
return key
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Whether the caller asked for a write that is supposed to end up encrypted.
|
|
78
|
+
*
|
|
79
|
+
* `resolveDekKey` returns `null` for several situations. Two are intentional
|
|
80
|
+
* no-ops — no encryption service is wired, or the record has no tenant scope —
|
|
81
|
+
* and one is the operator deliberately running unencrypted
|
|
82
|
+
* (`TENANT_DATA_ENCRYPTION=no`). Anything else means the caller asked for an
|
|
83
|
+
* encrypted write that could not be performed, which is worth reporting.
|
|
84
|
+
*
|
|
85
|
+
* This deliberately checks the `TENANT_DATA_ENCRYPTION` env toggle rather than
|
|
86
|
+
* `service.isEnabled()`. `isEnabled()` folds the toggle together with KMS
|
|
87
|
+
* health, and an unreachable Vault with no fallback secret resolves to
|
|
88
|
+
* `NoopKmsService`, whose `isHealthy()` is false whenever encryption is on — so
|
|
89
|
+
* gating on it would stay silent during exactly the outage this warning exists
|
|
90
|
+
* to surface. The env toggle alone expresses the operator's intent.
|
|
91
|
+
*/
|
|
92
|
+
function isEncryptionExpected(
|
|
93
|
+
service: TenantDataEncryptionService | null,
|
|
94
|
+
tenantId: string | null | undefined,
|
|
95
|
+
): boolean {
|
|
96
|
+
if (!service || !(tenantId ?? null)) return false
|
|
97
|
+
return isTenantDataEncryptionEnabled()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// One warning per tenant/entity/field per OUTAGE. A key-store outage makes this
|
|
101
|
+
// branch run for every field of every write, so the warning is throttled — but
|
|
102
|
+
// the entries for a tenant are dropped again as soon as one of its writes
|
|
103
|
+
// encrypts successfully. Throttling for the lifetime of the process instead
|
|
104
|
+
// would report the first outage and silently swallow every later one, which is
|
|
105
|
+
// the failure this warning exists to make visible.
|
|
106
|
+
const PLAINTEXT_FALLBACK_WARN_CAP = 5000
|
|
107
|
+
const plaintextFallbackWarned = new Set<string>()
|
|
108
|
+
|
|
109
|
+
/** Test seam: the warn-once cache is process-global by design. */
|
|
110
|
+
export function resetEncryptedFieldPlaintextFallbackWarnCache(): void {
|
|
111
|
+
plaintextFallbackWarned.clear()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Forget a tenant's plaintext-fallback warnings once its key resolves again, so
|
|
116
|
+
* a later outage is reported instead of being throttled away by the previous
|
|
117
|
+
* one. The `size` guard keeps the healthy path — an empty set — at O(1).
|
|
118
|
+
*/
|
|
119
|
+
function clearPlaintextFallbackWarnings(tenantId: string | null | undefined): void {
|
|
120
|
+
if (!plaintextFallbackWarned.size) return
|
|
121
|
+
const prefix = `${tenantId ?? null}|`
|
|
122
|
+
for (const warnKey of plaintextFallbackWarned) {
|
|
123
|
+
if (warnKey.startsWith(prefix)) plaintextFallbackWarned.delete(warnKey)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function warnOnPlaintextFallback(
|
|
128
|
+
tenantId: string | null | undefined,
|
|
129
|
+
options?: EncryptCustomFieldOptions,
|
|
130
|
+
): void {
|
|
131
|
+
try {
|
|
132
|
+
const scopedTenantId = tenantId ?? null
|
|
133
|
+
const entity = options?.entityId ?? null
|
|
134
|
+
const field = options?.fieldKey ?? null
|
|
135
|
+
const warnKey = `${scopedTenantId}|${entity ?? 'unknown'}|${field ?? 'unknown'}`
|
|
136
|
+
if (plaintextFallbackWarned.has(warnKey)) return
|
|
137
|
+
if (plaintextFallbackWarned.size >= PLAINTEXT_FALLBACK_WARN_CAP) {
|
|
138
|
+
const oldest = plaintextFallbackWarned.values().next().value
|
|
139
|
+
if (oldest !== undefined) plaintextFallbackWarned.delete(oldest)
|
|
140
|
+
}
|
|
141
|
+
plaintextFallbackWarned.add(warnKey)
|
|
142
|
+
logger.warn('Custom field configured as encrypted was stored as plaintext', {
|
|
143
|
+
tenantId: scopedTenantId,
|
|
144
|
+
entity,
|
|
145
|
+
field,
|
|
146
|
+
hint: 'The tenant data encryption key could not be read or created (KMS/Vault unavailable, or DEK creation failed), so the value was written unencrypted. Restore key access and re-save the affected records.',
|
|
147
|
+
})
|
|
148
|
+
} catch {
|
|
149
|
+
// A diagnostic must never break the write it is diagnosing.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type EncryptCustomFieldOptions = {
|
|
154
|
+
/** Entity the value belongs to, e.g. `customers:person`. Used only to identify the field in diagnostics. */
|
|
155
|
+
entityId?: string | null
|
|
156
|
+
/** Custom field key, e.g. from `CustomFieldDef.key`. Used only to identify the field in diagnostics. */
|
|
157
|
+
fieldKey?: string | null
|
|
158
|
+
}
|
|
159
|
+
|
|
72
160
|
export async function encryptCustomFieldValue(
|
|
73
161
|
value: unknown,
|
|
74
162
|
tenantId: string | null | undefined,
|
|
75
163
|
service: TenantDataEncryptionService | null,
|
|
76
164
|
cache?: Map<string | null, string | null>,
|
|
165
|
+
options?: EncryptCustomFieldOptions,
|
|
77
166
|
): Promise<unknown> {
|
|
78
167
|
if (value === undefined || value === null) return value
|
|
79
168
|
const key = await resolveDekKey(service, tenantId, cache, { createIfMissing: true })
|
|
80
|
-
if (!key)
|
|
169
|
+
if (!key) {
|
|
170
|
+
// Key resolution failed for a field the operator configured as encrypted.
|
|
171
|
+
// The write still goes through as plaintext (failing it would drop data on
|
|
172
|
+
// a transient outage), but it must not be silent — issue #5921.
|
|
173
|
+
if (isEncryptionExpected(service, tenantId)) warnOnPlaintextFallback(tenantId, options)
|
|
174
|
+
return value
|
|
175
|
+
}
|
|
176
|
+
clearPlaintextFallbackWarnings(tenantId)
|
|
81
177
|
const serialized = typeof value === 'string' ? value : JSON.stringify(value)
|
|
82
178
|
return encryptWithAesGcm(serialized, key).value
|
|
83
179
|
}
|