@open-mercato/shared 0.8.1-develop.7207.1.901ef7e47a → 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/encryption/tenantDataEncryptionService.js +141 -30
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/widgets/extension-points.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/encryption/__tests__/customFieldValues.plaintext-fallback.test.ts +208 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +137 -0
- package/src/lib/encryption/customFieldValues.ts +97 -1
- package/src/lib/encryption/tenantDataEncryptionService.ts +168 -32
- package/src/lib/query/engine.ts +8 -7
- package/src/lib/search/config.ts +7 -0
- package/src/modules/widgets/extension-points.ts +9 -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": "AACA,SAAS,oBAAoB;AAC7B,SAAS,mBAAmB,yBAAyB;AACrD,SAAS,mCAAmC;AAC5C,SAAS,qCAAqC;AAE9C,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,aAAa,CAAC;AAcvE,MAAM,kCAAkC,oBAAI,IAAI;AAAA,EAC9C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAOD,SAAS,uBAAuB,MAA0C;AACxE,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,gCAAgC,IAAI,IAAI;AACjD;AAEA,MAAM,eAAe,oBAAI,QAAoD;AAEtE,SAAS,+BACd,IACA,UACoC;AACpC,MAAI,SAAU,QAAO;AACrB,QAAM,SAAS,aAAa,IAAI,EAAE;AAClC,MAAI,OAAQ,QAAO;AACnB,QAAM,UAAU,IAAI,4BAA4B,EAAS;AACzD,eAAa,IAAI,IAAI,OAAO;AAC5B,SAAO;AACT;AAEA,eAAe,cACb,SACA,UACA,OACA,MACwB;AACxB,QAAM,iBAAiB,YAAY;AACnC,MAAI,CAAC,WAAW,CAAC,QAAQ,UAAU,KAAK,CAAC,eAAgB,QAAO;AAChE,MAAI,OAAO,IAAI,cAAc,EAAG,QAAO,MAAM,IAAI,cAAc,KAAK;AACpE,QAAM,MAAM,MAAM,QAAQ,OAAO,cAAc;AAC/C,MAAI,MAAM,KAAK,OAAO;AACtB,MAAI,CAAC,OAAO,MAAM,mBAAmB,OAAO,QAAQ,cAAc,YAAY;AAC5E,UAAM,UAAU,MAAM,QAAQ,UAAU,cAAc;AACtD,UAAM,SAAS,OAAO;AAAA,EACxB;AACA,SAAO,IAAI,gBAAgB,GAAG;AAC9B,SAAO;AACT;AAkBA,SAAS,qBACP,SACA,UACS;AACT,MAAI,CAAC,WAAW,EAAE,YAAY,MAAO,QAAO;AAC5C,SAAO,8BAA8B;AACvC;AAQA,MAAM,8BAA8B;AACpC,MAAM,0BAA0B,oBAAI,IAAY;AAGzC,SAAS,gDAAsD;AACpE,0BAAwB,MAAM;AAChC;AAOA,SAAS,+BAA+B,UAA2C;AACjF,MAAI,CAAC,wBAAwB,KAAM;AACnC,QAAM,SAAS,GAAG,YAAY,IAAI;AAClC,aAAW,WAAW,yBAAyB;AAC7C,QAAI,QAAQ,WAAW,MAAM,EAAG,yBAAwB,OAAO,OAAO;AAAA,EACxE;AACF;AAEA,SAAS,wBACP,UACA,SACM;AACN,MAAI;AACF,UAAM,iBAAiB,YAAY;AACnC,UAAM,SAAS,SAAS,YAAY;AACpC,UAAM,QAAQ,SAAS,YAAY;AACnC,UAAM,UAAU,GAAG,cAAc,IAAI,UAAU,SAAS,IAAI,SAAS,SAAS;AAC9E,QAAI,wBAAwB,IAAI,OAAO,EAAG;AAC1C,QAAI,wBAAwB,QAAQ,6BAA6B;AAC/D,YAAM,SAAS,wBAAwB,OAAO,EAAE,KAAK,EAAE;AACvD,UAAI,WAAW,OAAW,yBAAwB,OAAO,MAAM;AAAA,IACjE;AACA,4BAAwB,IAAI,OAAO;AACnC,WAAO,KAAK,gEAAgE;AAAA,MAC1E,UAAU;AAAA,MACV;AAAA,MACA;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH,QAAQ;AAAA,EAER;AACF;AASA,eAAsB,wBACpB,OACA,UACA,SACA,OACA,SACkB;AAClB,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,QAAM,MAAM,MAAM,cAAc,SAAS,UAAU,OAAO,EAAE,iBAAiB,KAAK,CAAC;AACnF,MAAI,CAAC,KAAK;AAIR,QAAI,qBAAqB,SAAS,QAAQ,EAAG,yBAAwB,UAAU,OAAO;AACtF,WAAO;AAAA,EACT;AACA,iCAA+B,QAAQ;AACvC,QAAM,aAAa,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AAC3E,SAAO,kBAAkB,YAAY,GAAG,EAAE;AAC5C;AAEA,eAAsB,wBACpB,OACA,UACA,SACA,OACA,SACkB;AAClB,MAAI,UAAU,UAAa,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO;AAC/E,QAAM,MAAM,MAAM,cAAc,SAAS,UAAU,KAAK;AACxD,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,YAAY,kBAAkB,OAAO,GAAG;AAC9C,MAAI,cAAc,KAAM,QAAO;AAC/B,MAAI,uBAAuB,SAAS,QAAQ,IAAI,EAAG,QAAO;AAC1D,MAAI;AACF,WAAO,KAAK,MAAM,SAAS;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,6 +5,7 @@ import { createLogger } from "../logger/index.js";
|
|
|
5
5
|
const logger = createLogger("shared").child({ component: "tenant-encryption" });
|
|
6
6
|
const MAP_MISS_TTL_MS = 5 * 60 * 1e3;
|
|
7
7
|
const DEK_CACHE_TTL_MS = 15 * 60 * 1e3;
|
|
8
|
+
const AGGREGATE_CACHE_TTL_MS = 300 * 1e3;
|
|
8
9
|
function cacheKey(key) {
|
|
9
10
|
return [
|
|
10
11
|
"encmap",
|
|
@@ -13,6 +14,9 @@ function cacheKey(key) {
|
|
|
13
14
|
key.organizationId ?? "null"
|
|
14
15
|
].join(":");
|
|
15
16
|
}
|
|
17
|
+
function allOrganizationsCacheKey(entityId, tenantId) {
|
|
18
|
+
return ["encmap-all-orgs", entityId.toLowerCase(), tenantId ?? "null"].join(":");
|
|
19
|
+
}
|
|
16
20
|
function debug(event, payload) {
|
|
17
21
|
if (!isEncryptionDebugEnabled()) return;
|
|
18
22
|
try {
|
|
@@ -45,9 +49,36 @@ function isEncryptedWithDek(value, dek) {
|
|
|
45
49
|
if (parts.length !== 4 || parts[3] !== "v1") return false;
|
|
46
50
|
return decryptWithAesGcm(value, dek.key) !== null;
|
|
47
51
|
}
|
|
48
|
-
function
|
|
52
|
+
function normalizeEncryptedFieldRules(fields) {
|
|
49
53
|
if (!Array.isArray(fields)) return [];
|
|
50
|
-
|
|
54
|
+
const rules = [];
|
|
55
|
+
for (const rule of fields) {
|
|
56
|
+
if (!rule || typeof rule !== "object") continue;
|
|
57
|
+
const field = rule.field;
|
|
58
|
+
if (typeof field !== "string" || field.trim().length === 0) continue;
|
|
59
|
+
rules.push({ field, hashField: typeof rule.hashField === "string" ? rule.hashField : null });
|
|
60
|
+
}
|
|
61
|
+
return rules;
|
|
62
|
+
}
|
|
63
|
+
function normalizeEncryptedFieldNames(fields) {
|
|
64
|
+
return normalizeEncryptedFieldRules(fields).map((rule) => rule.field);
|
|
65
|
+
}
|
|
66
|
+
function mergeEncryptedFieldRules(groups) {
|
|
67
|
+
const merged = [];
|
|
68
|
+
const byField = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const group of groups) {
|
|
70
|
+
for (const rule of group) {
|
|
71
|
+
const existing = byField.get(rule.field);
|
|
72
|
+
if (!existing) {
|
|
73
|
+
const copy = { field: rule.field, hashField: rule.hashField ?? null };
|
|
74
|
+
byField.set(rule.field, copy);
|
|
75
|
+
merged.push(copy);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!existing.hashField && rule.hashField) existing.hashField = rule.hashField;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return merged;
|
|
51
82
|
}
|
|
52
83
|
function readEncryptedFieldsJson(row) {
|
|
53
84
|
const raw = row.fields_json ?? row.fieldsJson;
|
|
@@ -77,6 +108,7 @@ class TenantDataEncryptionService {
|
|
|
77
108
|
constructor(em, opts) {
|
|
78
109
|
this.em = em;
|
|
79
110
|
this.memoryCache = TenantDataEncryptionService.globalMemoryCache;
|
|
111
|
+
this.aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache;
|
|
80
112
|
this.dekCache = TenantDataEncryptionService.globalDekCache;
|
|
81
113
|
this.inflightDeks = TenantDataEncryptionService.globalInflightDeks;
|
|
82
114
|
this.inflightMaps = TenantDataEncryptionService.globalInflightMaps;
|
|
@@ -90,6 +122,9 @@ class TenantDataEncryptionService {
|
|
|
90
122
|
static {
|
|
91
123
|
this.globalMemoryCache = /* @__PURE__ */ new Map();
|
|
92
124
|
}
|
|
125
|
+
static {
|
|
126
|
+
this.globalAggregateMemoryCache = /* @__PURE__ */ new Map();
|
|
127
|
+
}
|
|
93
128
|
static {
|
|
94
129
|
this.globalInflightMaps = /* @__PURE__ */ new Map();
|
|
95
130
|
}
|
|
@@ -246,7 +281,7 @@ class TenantDataEncryptionService {
|
|
|
246
281
|
}
|
|
247
282
|
return this.applySystemDefault(null, key.entityId);
|
|
248
283
|
}
|
|
249
|
-
async
|
|
284
|
+
async fetchAllOrganizationFieldRules(entityId, tenantId) {
|
|
250
285
|
const conn = getSqlConnection(this.em);
|
|
251
286
|
if (!conn) return [];
|
|
252
287
|
const sql = `
|
|
@@ -260,22 +295,86 @@ class TenantDataEncryptionService {
|
|
|
260
295
|
`;
|
|
261
296
|
const rows = await conn.execute(sql, [entityId, tenantId]);
|
|
262
297
|
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
263
|
-
const
|
|
298
|
+
const groups = [];
|
|
264
299
|
for (const row of rows) {
|
|
265
300
|
if (!row || typeof row !== "object") continue;
|
|
266
|
-
|
|
267
|
-
|
|
301
|
+
groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row)));
|
|
302
|
+
}
|
|
303
|
+
return mergeEncryptedFieldRules(groups);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`
|
|
307
|
+
* and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached
|
|
308
|
+
* read this used to be would add a round-trip per flush and per load (#5949).
|
|
309
|
+
*/
|
|
310
|
+
async getAllOrganizationFieldRules(entityId, tenantId) {
|
|
311
|
+
const tag = allOrganizationsCacheKey(entityId, tenantId);
|
|
312
|
+
const missExpiresAt = this.missCache.get(tag);
|
|
313
|
+
if (missExpiresAt) {
|
|
314
|
+
if (missExpiresAt > Date.now()) return [];
|
|
315
|
+
this.missCache.delete(tag);
|
|
316
|
+
}
|
|
317
|
+
const mem = this.aggregateMemoryCache.get(tag);
|
|
318
|
+
if (mem) {
|
|
319
|
+
if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields;
|
|
320
|
+
this.aggregateMemoryCache.delete(tag);
|
|
321
|
+
}
|
|
322
|
+
if (this.cache && typeof this.cache.get === "function") {
|
|
323
|
+
const cached = await this.cache.get(tag);
|
|
324
|
+
if (cached) {
|
|
325
|
+
const record = cached;
|
|
326
|
+
this.aggregateMemoryCache.set(tag, { at: Date.now(), record });
|
|
327
|
+
return record.fields;
|
|
268
328
|
}
|
|
269
329
|
}
|
|
270
|
-
|
|
330
|
+
const inflight = this.inflightMaps.get(tag);
|
|
331
|
+
if (inflight) return (await inflight)?.fields ?? [];
|
|
332
|
+
const pending = (async () => {
|
|
333
|
+
const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId);
|
|
334
|
+
return fields.length ? { entityId, fields } : null;
|
|
335
|
+
})();
|
|
336
|
+
this.inflightMaps.set(tag, pending);
|
|
337
|
+
let loaded;
|
|
338
|
+
try {
|
|
339
|
+
loaded = await pending;
|
|
340
|
+
} finally {
|
|
341
|
+
this.inflightMaps.delete(tag);
|
|
342
|
+
}
|
|
343
|
+
if (!loaded) {
|
|
344
|
+
this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS);
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
this.missCache.delete(tag);
|
|
348
|
+
this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded });
|
|
349
|
+
if (this.cache && typeof this.cache.set === "function") {
|
|
350
|
+
await this.cache.set(tag, loaded, { ttl: 300 });
|
|
351
|
+
}
|
|
352
|
+
return loaded.fields;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The field rules that are actually encrypted at rest for a scope.
|
|
356
|
+
*
|
|
357
|
+
* At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but
|
|
358
|
+
* rows of the same entity may carry fields declared solely by an organization-scoped map. Reading
|
|
359
|
+
* or writing such a row with the base map alone stores those fields as plaintext and hands
|
|
360
|
+
* callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in
|
|
361
|
+
* (#5949) — the same set `getEncryptedFieldNames` has reported since #2282.
|
|
362
|
+
*/
|
|
363
|
+
async resolveFieldRulesForScope(entityId, tenantId, organizationId, map) {
|
|
364
|
+
const mapRules = normalizeEncryptedFieldRules(map?.fields);
|
|
365
|
+
if (organizationId != null) return mapRules;
|
|
366
|
+
return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)]);
|
|
271
367
|
}
|
|
272
368
|
async invalidateMap(entityId, tenantId, organizationId) {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
369
|
+
const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)];
|
|
370
|
+
for (const tag of tags) {
|
|
371
|
+
this.memoryCache.delete(tag);
|
|
372
|
+
this.aggregateMemoryCache.delete(tag);
|
|
373
|
+
this.inflightMaps.delete(tag);
|
|
374
|
+
this.missCache.delete(tag);
|
|
375
|
+
if (this.cache && typeof this.cache.delete === "function") {
|
|
376
|
+
await this.cache.delete(tag);
|
|
377
|
+
}
|
|
279
378
|
}
|
|
280
379
|
}
|
|
281
380
|
// Force a flush of a tenant's cached DEK across the service-level cache and the
|
|
@@ -302,13 +401,13 @@ class TenantDataEncryptionService {
|
|
|
302
401
|
return [];
|
|
303
402
|
}
|
|
304
403
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
|
|
305
|
-
const fields =
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
return
|
|
404
|
+
const fields = await this.resolveFieldRulesForScope(
|
|
405
|
+
entityId,
|
|
406
|
+
tenantId ?? null,
|
|
407
|
+
organizationId ?? null,
|
|
408
|
+
map
|
|
409
|
+
);
|
|
410
|
+
return fields.map((rule) => rule.field);
|
|
312
411
|
}
|
|
313
412
|
encryptFields(obj, fields, dek) {
|
|
314
413
|
const clone = { ...obj };
|
|
@@ -367,18 +466,24 @@ class TenantDataEncryptionService {
|
|
|
367
466
|
return payload;
|
|
368
467
|
}
|
|
369
468
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
|
|
370
|
-
|
|
469
|
+
const fields = await this.resolveFieldRulesForScope(
|
|
470
|
+
entityId,
|
|
471
|
+
tenantId ?? null,
|
|
472
|
+
organizationId ?? null,
|
|
473
|
+
map
|
|
474
|
+
);
|
|
475
|
+
if (!fields.length) {
|
|
371
476
|
debug("\u26AA\uFE0F encrypt.skip.no-map", { entityId, tenantId });
|
|
372
477
|
return payload;
|
|
373
478
|
}
|
|
374
|
-
const keyId = resolveEncryptionKeyId(entityId, map
|
|
479
|
+
const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId);
|
|
375
480
|
const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false });
|
|
376
481
|
if (!dek) {
|
|
377
|
-
debug("\u26A0\uFE0F encrypt.skip.no-dek", { entityId, tenantId, keyScope: map
|
|
482
|
+
debug("\u26A0\uFE0F encrypt.skip.no-dek", { entityId, tenantId, keyScope: map?.keyScope ?? "tenant" });
|
|
378
483
|
return payload;
|
|
379
484
|
}
|
|
380
|
-
debug("\u{1F512} encrypt_entity", { entityId, tenantId, organizationId, fields:
|
|
381
|
-
return this.encryptFields(payload,
|
|
485
|
+
debug("\u{1F512} encrypt_entity", { entityId, tenantId, organizationId, fields: fields.length });
|
|
486
|
+
return this.encryptFields(payload, fields, dek);
|
|
382
487
|
}
|
|
383
488
|
async decryptEntityPayload(entityId, payload, tenantId, organizationId) {
|
|
384
489
|
if (!isTenantDataEncryptionEnabled()) {
|
|
@@ -386,18 +491,24 @@ class TenantDataEncryptionService {
|
|
|
386
491
|
return payload;
|
|
387
492
|
}
|
|
388
493
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
|
|
389
|
-
|
|
494
|
+
const fields = await this.resolveFieldRulesForScope(
|
|
495
|
+
entityId,
|
|
496
|
+
tenantId ?? null,
|
|
497
|
+
organizationId ?? null,
|
|
498
|
+
map
|
|
499
|
+
);
|
|
500
|
+
if (!fields.length) {
|
|
390
501
|
debug("\u26AA\uFE0F decrypt.skip.no-map", { entityId, tenantId });
|
|
391
502
|
return payload;
|
|
392
503
|
}
|
|
393
|
-
const keyId = resolveEncryptionKeyId(entityId, map
|
|
504
|
+
const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId);
|
|
394
505
|
const dek = await this.getDek(keyId);
|
|
395
506
|
if (!dek) {
|
|
396
|
-
debug("\u26A0\uFE0F decrypt.skip.no-dek", { entityId, tenantId, keyScope: map
|
|
507
|
+
debug("\u26A0\uFE0F decrypt.skip.no-dek", { entityId, tenantId, keyScope: map?.keyScope ?? "tenant" });
|
|
397
508
|
return payload;
|
|
398
509
|
}
|
|
399
|
-
debug("\u{1F513} decrypt_entity", { entityId, tenantId, organizationId, fields:
|
|
400
|
-
return this.decryptFields(payload,
|
|
510
|
+
debug("\u{1F513} decrypt_entity", { entityId, tenantId, organizationId, fields: fields.length });
|
|
511
|
+
return this.decryptFields(payload, fields, dek);
|
|
401
512
|
}
|
|
402
513
|
}
|
|
403
514
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/tenantDataEncryptionService.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'\nimport { createKmsService, type KmsService, type TenantDek } from './kms'\nimport { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\nimport type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'\n\nconst logger = createLogger('shared').child({ component: 'tenant-encryption' })\n\nexport type EncryptedFieldRule = {\n field: string\n hashField?: string | null\n}\n\nexport type EncryptionMapRecord = {\n entityId: string\n keyScope?: EncryptionKeyScope\n fields: EncryptedFieldRule[]\n}\n\ntype MapCacheKey = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n}\n\ntype SqlConnection = {\n execute(sql: string, params?: readonly unknown[]): Promise<unknown>\n}\n\nconst MAP_MISS_TTL_MS = 5 * 60 * 1000\n// Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked\n// up by long-lived processes without a restart (#2746). The service-level cache\n// previously had no TTL and shadowed the KMS's own 15-minute expiry.\nconst DEK_CACHE_TTL_MS = 15 * 60 * 1000\n\nfunction cacheKey(key: MapCacheKey): string {\n return [\n 'encmap',\n key.entityId.toLowerCase(),\n key.tenantId ?? 'null',\n key.organizationId ?? 'null',\n ].join(':')\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nconst toCamelCase = (value: string): string =>\n value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())\n\nfunction findKey(obj: Record<string, unknown>, key: string): string | null {\n const candidates = [key, toSnakeCase(key), toCamelCase(key)]\n for (const candidate of candidates) {\n if (Object.prototype.hasOwnProperty.call(obj, candidate)) return candidate\n }\n return null\n}\n\n/**\n * Decode a decrypted entity-field payload back into its original value.\n *\n * The encrypt path stores raw strings unwrapped and JSON-stringifies non-string\n * values. Blindly running `JSON.parse` on every decrypted value would coerce\n * text columns whose contents happen to be valid JSON primitives \u2014 e.g. the\n * string `\"123\"` \u2014 back into numbers/booleans, which then breaks string-typed\n * consumers (see issue #1734). Only restructure the value when the decrypted\n * payload is unambiguously a JSON object or array; otherwise return the raw\n * decrypted string. Numeric/boolean entity columns are not in any current\n * encryption map, so this is backward-compatible.\n *\n * NOTE (issue #1810 follow-up): `decryptFields` no longer calls this helper for\n * entity-field decryption \u2014 typed string columns whose contents happen to look\n * like JSON (e.g. a display name `{\"a\":1}`) must remain raw strings to avoid\n * downstream React-render crashes. Callers that legitimately need the parse\n * (audit-log jsonb columns, custom-field rotation, encryption CLI) MUST invoke\n * `parseDecryptedFieldValue` themselves on the decrypted payload.\n */\nexport function parseDecryptedFieldValue(decrypted: string): unknown {\n if (decrypted.length === 0) return decrypted\n const first = decrypted[0]\n if (first !== '{' && first !== '[') return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n\n/**\n * A value is only treated as \"already encrypted\" when it actually decrypts\n * under the tenant DEK \u2014 i.e. the AES-GCM authentication tag verifies. A purely\n * structural `<iv>:<ct>:<tag>:v1` shape check is forgeable: attacker-controlled\n * field values (e.g. their own profile email/phone) could impersonate ciphertext\n * to skip encryption-at-rest and the lookup hash entirely (issue #2720). Binding\n * the check to a successful authenticated decrypt makes forgery infeasible, so a\n * fake payload simply gets encrypted like any other plaintext.\n */\nfunction isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n return decryptWithAesGcm(value, dek.key) !== null\n}\n\nfunction normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {\n if (!Array.isArray(fields)) return []\n return fields\n .map((rule) => rule.field)\n .filter((field): field is string => typeof field === 'string' && field.trim().length > 0)\n}\n\nfunction readEncryptedFieldsJson(row: Record<string, unknown>): EncryptedFieldRule[] {\n const raw = row.fields_json ?? row.fieldsJson\n if (Array.isArray(raw)) return raw as EncryptedFieldRule[]\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return Array.isArray(parsed) ? parsed as EncryptedFieldRule[] : []\n } catch {\n return []\n }\n }\n return []\n}\n\n/**\n * The KMS key id an encryption map's payloads are sealed under: a system-scoped map\n * uses a per-entity key that exists before any tenant does, everything else uses the\n * tenant's own key. Exported so callers that need to probe key availability without\n * encrypting (the encryption CLIs) derive the same id instead of re-spelling the\n * `system:` convention (#5950).\n */\nexport function resolveEncryptionKeyId(\n entityId: string,\n keyScope: EncryptionKeyScope | undefined,\n tenantId: string | null | undefined\n): string | null {\n return keyScope === 'system' ? `system:${entityId}` : tenantId ?? null\n}\n\nfunction getSqlConnection(em: EntityManager): SqlConnection | null {\n const source = em as { getConnection?: () => unknown }\n const conn = source.getConnection?.()\n if (!conn || typeof conn !== 'object') return null\n const candidate = conn as { execute?: unknown }\n if (typeof candidate.execute !== 'function') return null\n return candidate as SqlConnection\n}\n\nexport class TenantDataEncryptionService {\n private static globalMemoryCache = new Map<string, EncryptionMapRecord>()\n private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()\n private static globalMissCache = new Map<string, number>()\n private readonly kms: KmsService\n private readonly cache?: CacheStrategy\n private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks\n private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps\n private readonly missCache = TenantDataEncryptionService.globalMissCache\n private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>\n\n constructor(\n private em: EntityManager,\n opts?: {\n cache?: CacheStrategy\n kms?: KmsService\n defaultEncryptionMaps?: readonly ModuleEncryptionMap[]\n }\n ) {\n this.cache = opts?.cache\n this.kms = opts?.kms ?? createKmsService()\n this.systemDefaultMaps = new Map(\n (opts?.defaultEncryptionMaps ?? [])\n .filter((map) => map.keyScope === 'system')\n .map((map) => [map.entityId, map]),\n )\n }\n\n isEnabled(): boolean {\n return isTenantDataEncryptionEnabled() && this.kms.isHealthy()\n }\n\n private isDekExpired(dek: TenantDek): boolean {\n return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS\n }\n\n async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {\n if (!tenantId) return null\n const cached = this.dekCache.get(tenantId)\n if (cached && !this.isDekExpired(cached)) return cached\n if (cached) this.dekCache.delete(tenantId)\n const dek = await this.kms.getTenantDek(tenantId)\n if (!dek) {\n debug('\uD83D\uDD0E dek.miss', { tenantId })\n } else {\n debug('\u2705 dek.hit', { tenantId })\n }\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n /**\n * Resolves the DEK an encrypt call should seal under, provisioning one when the\n * tenant has none yet.\n *\n * Provisioning writes real key material to the KMS/Vault backend, so it is a\n * state change \u2014 not a cache fill. Callers whose intent is only to preview or\n * check (\"would this row be encrypted?\") pass `createIfMissing: false` to get a\n * `null` instead, leaving KMS untouched (issue #5950). The default stays `true`\n * so every existing write path keeps provisioning on first use.\n */\n private async resolveDekForEncrypt(\n tenantId: string | null,\n options?: { createIfMissing?: boolean }\n ): Promise<TenantDek | null> {\n const existing = await this.getDek(tenantId)\n if (existing || !tenantId) return existing ?? null\n if (options?.createIfMissing === false) return null\n if (typeof this.kms.createTenantDek !== 'function') return existing ?? null\n // Dedupe concurrent first-time creation within this process so two callers\n // can't each generate a distinct DEK and overwrite one another (#2746).\n // Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).\n const pending = this.inflightDeks.get(tenantId)\n if (pending) return pending\n const creation = (async () => {\n const created = await this.kms.createTenantDek(tenantId)\n if (created) this.dekCache.set(tenantId, created)\n return created ?? null\n })()\n this.inflightDeks.set(tenantId, creation)\n try {\n return await creation\n } finally {\n this.inflightDeks.delete(tenantId)\n }\n }\n\n async createDek(tenantId: string): Promise<TenantDek | null> {\n const dek = await this.kms.createTenantDek(tenantId)\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n private async fetchMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n // Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.\n const conn = getSqlConnection(this.em)\n if (!conn) return null\n const sql = `\n select entity_id, fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not distinct from ?\n and is_active = true\n and deleted_at is null\n limit 1\n `\n const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null])\n const row = Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === 'object'\n ? rows[0] as Record<string, unknown>\n : null\n if (!row) return null\n return {\n entityId: String(row.entity_id ?? row.entityId ?? key.entityId),\n fields: readEncryptedFieldsJson(row),\n }\n }\n\n private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {\n const declared = this.systemDefaultMaps.get(entityId)\n if (!declared) return record\n const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({\n field: field.field,\n hashField: field.hashField ?? null,\n }))\n const declaredFields = new Set(fields.map((field) => field.field))\n for (const field of record?.fields ?? []) {\n if (!declaredFields.has(field.field)) fields.push(field)\n }\n return {\n entityId,\n keyScope: 'system',\n fields,\n }\n }\n\n private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n const shouldSkipLookup = (tag: string) => {\n const expiresAt = this.missCache.get(tag)\n if (!expiresAt) return false\n if (expiresAt > Date.now()) return true\n this.missCache.delete(tag)\n return false\n }\n const recordMiss = (tag: string) => {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n }\n\n const candidates: MapCacheKey[] = [\n key,\n { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },\n { entityId: key.entityId, tenantId: null, organizationId: null },\n ]\n for (const candidate of candidates) {\n const tag = cacheKey(candidate)\n if (shouldSkipLookup(tag)) continue\n if (this.inflightMaps.has(tag)) {\n const pending = this.inflightMaps.get(tag)!\n const resolved = await pending\n if (resolved) return this.applySystemDefault(resolved, key.entityId)\n }\n const mem = this.memoryCache.get(tag)\n if (mem) return this.applySystemDefault(mem, key.entityId)\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)\n }\n const pending = this.fetchMap(candidate)\n this.inflightMaps.set(tag, pending)\n const loaded = await pending\n this.inflightMaps.delete(tag)\n if (!loaded) {\n recordMiss(tag)\n debug('\uD83D\uDD0D encmap.miss', {\n entityId: candidate.entityId,\n tenantId: candidate.tenantId,\n organizationId: candidate.organizationId,\n })\n continue\n }\n this.missCache.delete(tag)\n this.memoryCache.set(tag, loaded)\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return this.applySystemDefault(loaded, key.entityId)\n }\n return this.applySystemDefault(null, key.entityId)\n }\n\n private async fetchAllOrganizationFieldNames(entityId: string, tenantId: string | null): Promise<string[]> {\n const conn = getSqlConnection(this.em)\n if (!conn) return []\n const sql = `\n select fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not null\n and is_active = true\n and deleted_at is null\n `\n const rows = await conn.execute(sql, [entityId, tenantId])\n if (!Array.isArray(rows) || rows.length === 0) return []\n const names = new Set<string>()\n for (const row of rows) {\n if (!row || typeof row !== 'object') continue\n for (const field of normalizeEncryptedFieldNames(readEncryptedFieldsJson(row as Record<string, unknown>))) {\n names.add(field)\n }\n }\n return Array.from(names)\n }\n\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n const tag = cacheKey({ entityId, tenantId, organizationId })\n this.memoryCache.delete(tag)\n this.inflightMaps.delete(tag)\n this.missCache.delete(tag)\n if (this.cache && typeof (this.cache as any).delete === 'function') {\n await (this.cache as any).delete(tag)\n }\n }\n\n // Force a flush of a tenant's cached DEK across the service-level cache and the\n // underlying KMS cache so an operator can pick up a rotated/revoked key without\n // a process restart (#2746).\n invalidateDek(tenantId: string): void {\n this.dekCache.delete(tenantId)\n this.inflightDeks.delete(tenantId)\n this.kms.invalidateDek?.(tenantId)\n }\n\n /**\n * Lists the fields an encryption map marks as encrypted at rest.\n *\n * `isEnabled()` folds the environment toggle together with KMS health, so by default an\n * unhealthy KMS reports \"nothing is encrypted\" \u2014 which is safe for write paths but wrong for\n * readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`\n * answers the on-disk question instead: it consults the map even when the KMS cannot currently\n * resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).\n */\n async getEncryptedFieldNames(\n entityId: string,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { ignoreRuntimeHealth?: boolean }\n ): Promise<string[]> {\n if (options?.ignoreRuntimeHealth) {\n if (!isTenantDataEncryptionEnabled()) return []\n } else if (!this.isEnabled()) {\n return []\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = new Set(normalizeEncryptedFieldNames(map?.fields))\n if (organizationId == null) {\n for (const field of await this.fetchAllOrganizationFieldNames(entityId, tenantId ?? null)) {\n fields.add(field)\n }\n }\n return Array.from(fields)\n }\n\n private encryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (value === null || value === undefined) continue\n // Avoid double-encrypting payloads that genuinely decrypt under this DEK.\n // A forged ciphertext-shaped string fails this check and is encrypted as\n // plaintext, closing the encryption-at-rest bypass (issue #2720).\n if (isEncryptedWithDek(value, dek)) continue\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n const payload = encryptWithAesGcm(serialized, dek.key)\n clone[key] = payload.value\n if (rule.hashField) {\n const hashKey = findKey(clone, rule.hashField) ?? rule.hashField\n clone[hashKey] = hashForLookup(serialized)\n }\n }\n return clone\n }\n\n private decryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n const maybeDecrypt = (payload: string): string | null => {\n const first = decryptWithAesGcm(payload, dek.key)\n if (first === null) return null\n // Handle accidental double-encryption: if the first pass still looks like a v1 payload, try once more.\n const parts = first.split(':')\n if (parts.length === 4 && parts[3] === 'v1') {\n const second = decryptWithAesGcm(first, dek.key)\n return second ?? first\n }\n return first\n }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (typeof value !== 'string') continue\n const decrypted = maybeDecrypt(value)\n if (decrypted === null) continue\n // Entity fields are typed columns (string/text). Never auto-parse to an object \u2014\n // it triggers React-render crashes when a string value happens to be valid JSON\n // (issue #1810 follow-up). Custom field values use a separate helper that\n // preserves their typed-JSON contract.\n clone[key] = decrypted\n }\n return clone\n }\n\n /**\n * Encrypts the fields an entity's encryption map covers.\n *\n * `options.createMissingDek` (default `true`) controls whether a tenant without\n * a DEK gets one provisioned as a side effect. Preview/check callers \u2014 most\n * notably `mercato entities rotate-encryption-key --dry-run` \u2014 pass `false` so a\n * read-only invocation cannot write key material to KMS (issue #5950). With\n * `false` and no existing DEK the payload is returned unchanged, exactly as it\n * is when the KMS declines to issue a key.\n */\n async encryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { createMissingDek?: boolean }\n ): Promise<Record<string, unknown>> {\n if (!this.isEnabled()) {\n debug('\u26AA\uFE0F encrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n if (!map || !map.fields?.length) {\n debug('\u26AA\uFE0F encrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map.keyScope, tenantId)\n const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false })\n if (!dek) {\n debug('\u26A0\uFE0F encrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD12 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })\n return this.encryptFields(payload, map.fields, dek)\n }\n\n async decryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null\n ): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled()) {\n debug('\u26AA\uFE0F decrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n if (!map || !map.fields?.length) {\n debug('\u26AA\uFE0F decrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map.keyScope, tenantId)\n const dek = await this.getDek(keyId)\n if (!dek) {\n debug('\u26A0\uFE0F decrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD13 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })\n return this.decryptFields(payload, map.fields, dek)\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'\nimport { createKmsService, type KmsService, type TenantDek } from './kms'\nimport { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\nimport type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'\n\nconst logger = createLogger('shared').child({ component: 'tenant-encryption' })\n\nexport type EncryptedFieldRule = {\n field: string\n hashField?: string | null\n}\n\nexport type EncryptionMapRecord = {\n entityId: string\n keyScope?: EncryptionKeyScope\n fields: EncryptedFieldRule[]\n}\n\ntype MapCacheKey = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n}\n\ntype SqlConnection = {\n execute(sql: string, params?: readonly unknown[]): Promise<unknown>\n}\n\nconst MAP_MISS_TTL_MS = 5 * 60 * 1000\n// Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked\n// up by long-lived processes without a restart (#2746). The service-level cache\n// previously had no TTL and shadowed the KMS's own 15-minute expiry.\nconst DEK_CACHE_TTL_MS = 15 * 60 * 1000\n// Matches the `{ ttl: 300 }` passed to the `CacheStrategy` layer for the all-organizations\n// aggregate, so a process that only ever hits its own memory entry (no shared cache configured,\n// or already warm) still picks up a runtime map edit within the same bound (#6066).\nconst AGGREGATE_CACHE_TTL_MS = 300 * 1000\n\nfunction cacheKey(key: MapCacheKey): string {\n return [\n 'encmap',\n key.entityId.toLowerCase(),\n key.tenantId ?? 'null',\n key.organizationId ?? 'null',\n ].join(':')\n}\n\n// Tag for the aggregate of every organization-scoped map of an entity/tenant. The prefix differs\n// from `cacheKey`'s in its first segment rather than by an extra one, so no entity id can spell a\n// tag that collides with an aggregate (`encmap:all-orgs:x:y` would be reachable both ways).\nfunction allOrganizationsCacheKey(entityId: string, tenantId: string | null): string {\n return ['encmap-all-orgs', entityId.toLowerCase(), tenantId ?? 'null'].join(':')\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nconst toCamelCase = (value: string): string =>\n value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())\n\nfunction findKey(obj: Record<string, unknown>, key: string): string | null {\n const candidates = [key, toSnakeCase(key), toCamelCase(key)]\n for (const candidate of candidates) {\n if (Object.prototype.hasOwnProperty.call(obj, candidate)) return candidate\n }\n return null\n}\n\n/**\n * Decode a decrypted entity-field payload back into its original value.\n *\n * The encrypt path stores raw strings unwrapped and JSON-stringifies non-string\n * values. Blindly running `JSON.parse` on every decrypted value would coerce\n * text columns whose contents happen to be valid JSON primitives \u2014 e.g. the\n * string `\"123\"` \u2014 back into numbers/booleans, which then breaks string-typed\n * consumers (see issue #1734). Only restructure the value when the decrypted\n * payload is unambiguously a JSON object or array; otherwise return the raw\n * decrypted string. Numeric/boolean entity columns are not in any current\n * encryption map, so this is backward-compatible.\n *\n * NOTE (issue #1810 follow-up): `decryptFields` no longer calls this helper for\n * entity-field decryption \u2014 typed string columns whose contents happen to look\n * like JSON (e.g. a display name `{\"a\":1}`) must remain raw strings to avoid\n * downstream React-render crashes. Callers that legitimately need the parse\n * (audit-log jsonb columns, custom-field rotation, encryption CLI) MUST invoke\n * `parseDecryptedFieldValue` themselves on the decrypted payload.\n */\nexport function parseDecryptedFieldValue(decrypted: string): unknown {\n if (decrypted.length === 0) return decrypted\n const first = decrypted[0]\n if (first !== '{' && first !== '[') return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n\n/**\n * A value is only treated as \"already encrypted\" when it actually decrypts\n * under the tenant DEK \u2014 i.e. the AES-GCM authentication tag verifies. A purely\n * structural `<iv>:<ct>:<tag>:v1` shape check is forgeable: attacker-controlled\n * field values (e.g. their own profile email/phone) could impersonate ciphertext\n * to skip encryption-at-rest and the lookup hash entirely (issue #2720). Binding\n * the check to a successful authenticated decrypt makes forgery infeasible, so a\n * fake payload simply gets encrypted like any other plaintext.\n */\nfunction isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n return decryptWithAesGcm(value, dek.key) !== null\n}\n\nfunction normalizeEncryptedFieldRules(\n fields: readonly { field?: unknown; hashField?: unknown }[] | null | undefined,\n): EncryptedFieldRule[] {\n if (!Array.isArray(fields)) return []\n const rules: EncryptedFieldRule[] = []\n for (const rule of fields) {\n if (!rule || typeof rule !== 'object') continue\n const field = rule.field\n if (typeof field !== 'string' || field.trim().length === 0) continue\n rules.push({ field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null })\n }\n return rules\n}\n\nfunction normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {\n return normalizeEncryptedFieldRules(fields).map((rule) => rule.field)\n}\n\n/**\n * Union of field rules, first declaration wins on the field name. A later duplicate only\n * contributes its `hashField` when the winning rule declares none, so merging an organization-scoped\n * map into a tenant-wide one never silently retargets an existing lookup-hash column.\n */\nfunction mergeEncryptedFieldRules(groups: readonly EncryptedFieldRule[][]): EncryptedFieldRule[] {\n const merged: EncryptedFieldRule[] = []\n const byField = new Map<string, EncryptedFieldRule>()\n for (const group of groups) {\n for (const rule of group) {\n const existing = byField.get(rule.field)\n if (!existing) {\n const copy: EncryptedFieldRule = { field: rule.field, hashField: rule.hashField ?? null }\n byField.set(rule.field, copy)\n merged.push(copy)\n continue\n }\n if (!existing.hashField && rule.hashField) existing.hashField = rule.hashField\n }\n }\n return merged\n}\n\nfunction readEncryptedFieldsJson(row: Record<string, unknown>): EncryptedFieldRule[] {\n const raw = row.fields_json ?? row.fieldsJson\n if (Array.isArray(raw)) return raw as EncryptedFieldRule[]\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return Array.isArray(parsed) ? parsed as EncryptedFieldRule[] : []\n } catch {\n return []\n }\n }\n return []\n}\n\n/**\n * The KMS key id an encryption map's payloads are sealed under: a system-scoped map\n * uses a per-entity key that exists before any tenant does, everything else uses the\n * tenant's own key. Exported so callers that need to probe key availability without\n * encrypting (the encryption CLIs) derive the same id instead of re-spelling the\n * `system:` convention (#5950).\n */\nexport function resolveEncryptionKeyId(\n entityId: string,\n keyScope: EncryptionKeyScope | undefined,\n tenantId: string | null | undefined\n): string | null {\n return keyScope === 'system' ? `system:${entityId}` : tenantId ?? null\n}\n\nfunction getSqlConnection(em: EntityManager): SqlConnection | null {\n const source = em as { getConnection?: () => unknown }\n const conn = source.getConnection?.()\n if (!conn || typeof conn !== 'object') return null\n const candidate = conn as { execute?: unknown }\n if (typeof candidate.execute !== 'function') return null\n return candidate as SqlConnection\n}\n\nexport class TenantDataEncryptionService {\n private static globalMemoryCache = new Map<string, EncryptionMapRecord>()\n private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()\n private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()\n private static globalMissCache = new Map<string, number>()\n private readonly kms: KmsService\n private readonly cache?: CacheStrategy\n private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache\n private readonly aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks\n private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps\n private readonly missCache = TenantDataEncryptionService.globalMissCache\n private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>\n\n constructor(\n private em: EntityManager,\n opts?: {\n cache?: CacheStrategy\n kms?: KmsService\n defaultEncryptionMaps?: readonly ModuleEncryptionMap[]\n }\n ) {\n this.cache = opts?.cache\n this.kms = opts?.kms ?? createKmsService()\n this.systemDefaultMaps = new Map(\n (opts?.defaultEncryptionMaps ?? [])\n .filter((map) => map.keyScope === 'system')\n .map((map) => [map.entityId, map]),\n )\n }\n\n isEnabled(): boolean {\n return isTenantDataEncryptionEnabled() && this.kms.isHealthy()\n }\n\n private isDekExpired(dek: TenantDek): boolean {\n return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS\n }\n\n async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {\n if (!tenantId) return null\n const cached = this.dekCache.get(tenantId)\n if (cached && !this.isDekExpired(cached)) return cached\n if (cached) this.dekCache.delete(tenantId)\n const dek = await this.kms.getTenantDek(tenantId)\n if (!dek) {\n debug('\uD83D\uDD0E dek.miss', { tenantId })\n } else {\n debug('\u2705 dek.hit', { tenantId })\n }\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n /**\n * Resolves the DEK an encrypt call should seal under, provisioning one when the\n * tenant has none yet.\n *\n * Provisioning writes real key material to the KMS/Vault backend, so it is a\n * state change \u2014 not a cache fill. Callers whose intent is only to preview or\n * check (\"would this row be encrypted?\") pass `createIfMissing: false` to get a\n * `null` instead, leaving KMS untouched (issue #5950). The default stays `true`\n * so every existing write path keeps provisioning on first use.\n */\n private async resolveDekForEncrypt(\n tenantId: string | null,\n options?: { createIfMissing?: boolean }\n ): Promise<TenantDek | null> {\n const existing = await this.getDek(tenantId)\n if (existing || !tenantId) return existing ?? null\n if (options?.createIfMissing === false) return null\n if (typeof this.kms.createTenantDek !== 'function') return existing ?? null\n // Dedupe concurrent first-time creation within this process so two callers\n // can't each generate a distinct DEK and overwrite one another (#2746).\n // Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).\n const pending = this.inflightDeks.get(tenantId)\n if (pending) return pending\n const creation = (async () => {\n const created = await this.kms.createTenantDek(tenantId)\n if (created) this.dekCache.set(tenantId, created)\n return created ?? null\n })()\n this.inflightDeks.set(tenantId, creation)\n try {\n return await creation\n } finally {\n this.inflightDeks.delete(tenantId)\n }\n }\n\n async createDek(tenantId: string): Promise<TenantDek | null> {\n const dek = await this.kms.createTenantDek(tenantId)\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n private async fetchMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n // Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.\n const conn = getSqlConnection(this.em)\n if (!conn) return null\n const sql = `\n select entity_id, fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not distinct from ?\n and is_active = true\n and deleted_at is null\n limit 1\n `\n const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null])\n const row = Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === 'object'\n ? rows[0] as Record<string, unknown>\n : null\n if (!row) return null\n return {\n entityId: String(row.entity_id ?? row.entityId ?? key.entityId),\n fields: readEncryptedFieldsJson(row),\n }\n }\n\n private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {\n const declared = this.systemDefaultMaps.get(entityId)\n if (!declared) return record\n const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({\n field: field.field,\n hashField: field.hashField ?? null,\n }))\n const declaredFields = new Set(fields.map((field) => field.field))\n for (const field of record?.fields ?? []) {\n if (!declaredFields.has(field.field)) fields.push(field)\n }\n return {\n entityId,\n keyScope: 'system',\n fields,\n }\n }\n\n private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n const shouldSkipLookup = (tag: string) => {\n const expiresAt = this.missCache.get(tag)\n if (!expiresAt) return false\n if (expiresAt > Date.now()) return true\n this.missCache.delete(tag)\n return false\n }\n const recordMiss = (tag: string) => {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n }\n\n const candidates: MapCacheKey[] = [\n key,\n { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },\n { entityId: key.entityId, tenantId: null, organizationId: null },\n ]\n for (const candidate of candidates) {\n const tag = cacheKey(candidate)\n if (shouldSkipLookup(tag)) continue\n if (this.inflightMaps.has(tag)) {\n const pending = this.inflightMaps.get(tag)!\n const resolved = await pending\n if (resolved) return this.applySystemDefault(resolved, key.entityId)\n }\n const mem = this.memoryCache.get(tag)\n if (mem) return this.applySystemDefault(mem, key.entityId)\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)\n }\n const pending = this.fetchMap(candidate)\n this.inflightMaps.set(tag, pending)\n const loaded = await pending\n this.inflightMaps.delete(tag)\n if (!loaded) {\n recordMiss(tag)\n debug('\uD83D\uDD0D encmap.miss', {\n entityId: candidate.entityId,\n tenantId: candidate.tenantId,\n organizationId: candidate.organizationId,\n })\n continue\n }\n this.missCache.delete(tag)\n this.memoryCache.set(tag, loaded)\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return this.applySystemDefault(loaded, key.entityId)\n }\n return this.applySystemDefault(null, key.entityId)\n }\n\n private async fetchAllOrganizationFieldRules(\n entityId: string,\n tenantId: string | null,\n ): Promise<EncryptedFieldRule[]> {\n const conn = getSqlConnection(this.em)\n if (!conn) return []\n const sql = `\n select fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not null\n and is_active = true\n and deleted_at is null\n `\n const rows = await conn.execute(sql, [entityId, tenantId])\n if (!Array.isArray(rows) || rows.length === 0) return []\n const groups: EncryptedFieldRule[][] = []\n for (const row of rows) {\n if (!row || typeof row !== 'object') continue\n groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))\n }\n return mergeEncryptedFieldRules(groups)\n }\n\n /**\n * Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`\n * and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached\n * read this used to be would add a round-trip per flush and per load (#5949).\n */\n private async getAllOrganizationFieldRules(\n entityId: string,\n tenantId: string | null,\n ): Promise<EncryptedFieldRule[]> {\n const tag = allOrganizationsCacheKey(entityId, tenantId)\n const missExpiresAt = this.missCache.get(tag)\n if (missExpiresAt) {\n if (missExpiresAt > Date.now()) return []\n this.missCache.delete(tag)\n }\n const mem = this.aggregateMemoryCache.get(tag)\n if (mem) {\n if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields\n this.aggregateMemoryCache.delete(tag)\n }\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) {\n const record = cached as EncryptionMapRecord\n this.aggregateMemoryCache.set(tag, { at: Date.now(), record })\n return record.fields\n }\n }\n const inflight = this.inflightMaps.get(tag)\n if (inflight) return (await inflight)?.fields ?? []\n const pending = (async (): Promise<EncryptionMapRecord | null> => {\n const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)\n return fields.length ? { entityId, fields } : null\n })()\n this.inflightMaps.set(tag, pending)\n let loaded: EncryptionMapRecord | null\n try {\n loaded = await pending\n } finally {\n this.inflightMaps.delete(tag)\n }\n if (!loaded) {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n return []\n }\n this.missCache.delete(tag)\n this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded })\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return loaded.fields\n }\n\n /**\n * The field rules that are actually encrypted at rest for a scope.\n *\n * At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but\n * rows of the same entity may carry fields declared solely by an organization-scoped map. Reading\n * or writing such a row with the base map alone stores those fields as plaintext and hands\n * callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in\n * (#5949) \u2014 the same set `getEncryptedFieldNames` has reported since #2282.\n */\n private async resolveFieldRulesForScope(\n entityId: string,\n tenantId: string | null,\n organizationId: string | null,\n map: EncryptionMapRecord | null,\n ): Promise<EncryptedFieldRule[]> {\n const mapRules = normalizeEncryptedFieldRules(map?.fields)\n if (organizationId != null) return mapRules\n return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)])\n }\n\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)]\n for (const tag of tags) {\n this.memoryCache.delete(tag)\n this.aggregateMemoryCache.delete(tag)\n this.inflightMaps.delete(tag)\n this.missCache.delete(tag)\n if (this.cache && typeof (this.cache as any).delete === 'function') {\n await (this.cache as any).delete(tag)\n }\n }\n }\n\n // Force a flush of a tenant's cached DEK across the service-level cache and the\n // underlying KMS cache so an operator can pick up a rotated/revoked key without\n // a process restart (#2746).\n invalidateDek(tenantId: string): void {\n this.dekCache.delete(tenantId)\n this.inflightDeks.delete(tenantId)\n this.kms.invalidateDek?.(tenantId)\n }\n\n /**\n * Lists the fields an encryption map marks as encrypted at rest.\n *\n * `isEnabled()` folds the environment toggle together with KMS health, so by default an\n * unhealthy KMS reports \"nothing is encrypted\" \u2014 which is safe for write paths but wrong for\n * readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`\n * answers the on-disk question instead: it consults the map even when the KMS cannot currently\n * resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).\n */\n async getEncryptedFieldNames(\n entityId: string,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { ignoreRuntimeHealth?: boolean }\n ): Promise<string[]> {\n if (options?.ignoreRuntimeHealth) {\n if (!isTenantDataEncryptionEnabled()) return []\n } else if (!this.isEnabled()) {\n return []\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n return fields.map((rule) => rule.field)\n }\n\n private encryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (value === null || value === undefined) continue\n // Avoid double-encrypting payloads that genuinely decrypt under this DEK.\n // A forged ciphertext-shaped string fails this check and is encrypted as\n // plaintext, closing the encryption-at-rest bypass (issue #2720).\n if (isEncryptedWithDek(value, dek)) continue\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n const payload = encryptWithAesGcm(serialized, dek.key)\n clone[key] = payload.value\n if (rule.hashField) {\n const hashKey = findKey(clone, rule.hashField) ?? rule.hashField\n clone[hashKey] = hashForLookup(serialized)\n }\n }\n return clone\n }\n\n private decryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n const maybeDecrypt = (payload: string): string | null => {\n const first = decryptWithAesGcm(payload, dek.key)\n if (first === null) return null\n // Handle accidental double-encryption: if the first pass still looks like a v1 payload, try once more.\n const parts = first.split(':')\n if (parts.length === 4 && parts[3] === 'v1') {\n const second = decryptWithAesGcm(first, dek.key)\n return second ?? first\n }\n return first\n }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (typeof value !== 'string') continue\n const decrypted = maybeDecrypt(value)\n if (decrypted === null) continue\n // Entity fields are typed columns (string/text). Never auto-parse to an object \u2014\n // it triggers React-render crashes when a string value happens to be valid JSON\n // (issue #1810 follow-up). Custom field values use a separate helper that\n // preserves their typed-JSON contract.\n clone[key] = decrypted\n }\n return clone\n }\n\n /**\n * Encrypts the fields an entity's encryption map covers.\n *\n * `options.createMissingDek` (default `true`) controls whether a tenant without\n * a DEK gets one provisioned as a side effect. Preview/check callers \u2014 most\n * notably `mercato entities rotate-encryption-key --dry-run` \u2014 pass `false` so a\n * read-only invocation cannot write key material to KMS (issue #5950). With\n * `false` and no existing DEK the payload is returned unchanged, exactly as it\n * is when the KMS declines to issue a key.\n */\n async encryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { createMissingDek?: boolean }\n ): Promise<Record<string, unknown>> {\n if (!this.isEnabled()) {\n debug('\u26AA\uFE0F encrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n if (!fields.length) {\n debug('\u26AA\uFE0F encrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)\n const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false })\n if (!dek) {\n debug('\u26A0\uFE0F encrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD12 encrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })\n return this.encryptFields(payload, fields, dek)\n }\n\n async decryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null\n ): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled()) {\n debug('\u26AA\uFE0F decrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n if (!fields.length) {\n debug('\u26AA\uFE0F decrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)\n const dek = await this.getDek(keyId)\n if (!dek) {\n debug('\u26A0\uFE0F decrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD13 decrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })\n return this.decryptFields(payload, fields, dek)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["pending"]
|
|
7
7
|
}
|