@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -9,10 +9,19 @@ import {
9
9
  import { createKmsService } from "./kms.js";
10
10
  import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from "./toggles.js";
11
11
  import { createLogger } from "../logger/index.js";
12
+ import {
13
+ getEncryptionPolicyMemo,
14
+ invalidateEncryptionPolicyMemos
15
+ } from "./policyMemo.js";
12
16
  const logger = createLogger("shared").child({ component: "tenant-encryption" });
13
- const MAP_MISS_TTL_MS = 5 * 60 * 1e3;
14
17
  const DEK_CACHE_TTL_MS = 15 * 60 * 1e3;
15
- const AGGREGATE_CACHE_TTL_MS = 300 * 1e3;
18
+ function readScopeId(row, column, property) {
19
+ const value = row[column] ?? row[property];
20
+ return value == null ? null : String(value).toLowerCase();
21
+ }
22
+ function sameScopeId(value, expected) {
23
+ return value === (expected == null ? null : expected.toLowerCase());
24
+ }
16
25
  function cacheKey(key) {
17
26
  return [
18
27
  "encmap",
@@ -21,8 +30,8 @@ function cacheKey(key) {
21
30
  key.organizationId ?? "null"
22
31
  ].join(":");
23
32
  }
24
- function allOrganizationsCacheKey(entityId, tenantId) {
25
- return ["encmap-all-orgs", entityId.toLowerCase(), tenantId ?? "null"].join(":");
33
+ function encryptionMapPolicyVersionCacheKey(entityId) {
34
+ return ["encmap-policy-version", entityId.toLowerCase()].join(":");
26
35
  }
27
36
  function debug(event, payload) {
28
37
  if (!isEncryptionDebugEnabled()) return;
@@ -111,47 +120,27 @@ function readEncryptedFieldsJson(row) {
111
120
  function resolveEncryptionKeyId(entityId, keyScope, tenantId) {
112
121
  return keyScope === "system" ? `system:${entityId}` : tenantId ?? null;
113
122
  }
114
- function getSqlConnection(em) {
115
- const source = em;
116
- const conn = source.getConnection?.();
117
- if (!conn || typeof conn !== "object") return null;
118
- const candidate = conn;
123
+ function getSqlExecutor(em) {
124
+ const candidate = em;
119
125
  if (typeof candidate.execute !== "function") return null;
120
126
  return candidate;
121
127
  }
122
128
  class TenantDataEncryptionService {
123
129
  constructor(em, opts) {
124
130
  this.em = em;
125
- this.memoryCache = TenantDataEncryptionService.globalMemoryCache;
126
- this.aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache;
127
131
  this.dekCache = TenantDataEncryptionService.globalDekCache;
128
132
  this.inflightDeks = TenantDataEncryptionService.globalInflightDeks;
129
- this.inflightMaps = TenantDataEncryptionService.globalInflightMaps;
130
- this.missCache = TenantDataEncryptionService.globalMissCache;
131
- this.cache = opts?.cache;
132
133
  this.kms = opts?.kms ?? createKmsService();
133
134
  this.systemDefaultMaps = new Map(
134
135
  (opts?.defaultEncryptionMaps ?? []).filter((map) => map.keyScope === "system").map((map) => [map.entityId, map])
135
136
  );
136
137
  }
137
- static {
138
- this.globalMemoryCache = /* @__PURE__ */ new Map();
139
- }
140
- static {
141
- this.globalAggregateMemoryCache = /* @__PURE__ */ new Map();
142
- }
143
- static {
144
- this.globalInflightMaps = /* @__PURE__ */ new Map();
145
- }
146
138
  static {
147
139
  this.globalDekCache = /* @__PURE__ */ new Map();
148
140
  }
149
141
  static {
150
142
  this.globalInflightDeks = /* @__PURE__ */ new Map();
151
143
  }
152
- static {
153
- this.globalMissCache = /* @__PURE__ */ new Map();
154
- }
155
144
  isEnabled() {
156
145
  return isTenantDataEncryptionEnabled() && this.kms.isHealthy();
157
146
  }
@@ -206,80 +195,50 @@ class TenantDataEncryptionService {
206
195
  if (dek) this.dekCache.set(tenantId, dek);
207
196
  return dek;
208
197
  }
209
- async fetchMap(key) {
210
- const conn = getSqlConnection(this.em);
211
- if (!conn) return null;
198
+ /**
199
+ * Reads every row that can decide the policy for `key` in ONE statement: the exact scope, the
200
+ * tenant-wide scope, and the global scope — plus, at the tenant-wide scope, every
201
+ * organization-scoped map of the tenant for the all-organizations aggregate (#5949). Precedence
202
+ * and duplicate merging happen in JS so the result is identical to resolving the fallback chain
203
+ * one scope at a time.
204
+ */
205
+ async loadPolicy(key, em) {
206
+ const conn = getSqlExecutor(em ?? this.em);
207
+ if (!conn) {
208
+ throw new Error("[internal] Encryption policy lookup requires an EntityManager that can execute SQL");
209
+ }
210
+ const includeAllOrganizations = key.organizationId == null;
211
+ const scopeClause = includeAllOrganizations ? "tenant_id is not distinct from ?" : "(tenant_id is not distinct from ? and (organization_id = ? or organization_id is null))";
212
212
  const sql = `
213
- select entity_id, fields_json
213
+ select entity_id, tenant_id, organization_id, fields_json
214
214
  from encryption_maps
215
215
  where entity_id = ?
216
- and tenant_id is not distinct from ?
217
- and organization_id is not distinct from ?
218
216
  and is_active = true
219
217
  and deleted_at is null
220
- limit 1
218
+ and (${scopeClause} or (tenant_id is null and organization_id is null))
219
+ order by organization_id asc, created_at asc, id asc
221
220
  `;
222
- const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null]);
223
- const row = Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === "object" ? rows[0] : null;
224
- if (!row) return null;
225
- return {
226
- entityId: String(row.entity_id ?? row.entityId ?? key.entityId),
227
- fields: readEncryptedFieldsJson(row)
228
- };
229
- }
230
- applySystemDefault(record, entityId) {
231
- const declared = this.systemDefaultMaps.get(entityId);
232
- if (!declared) return record;
233
- const fields = declared.fields.map((field) => ({
234
- field: field.field,
235
- hashField: field.hashField ?? null
236
- }));
237
- const declaredFields = new Set(fields.map((field) => field.field));
238
- for (const field of record?.fields ?? []) {
239
- if (!declaredFields.has(field.field)) fields.push(field);
240
- }
241
- return {
242
- entityId,
243
- keyScope: "system",
244
- fields
245
- };
221
+ const params = includeAllOrganizations ? [key.entityId, key.tenantId] : [key.entityId, key.tenantId, key.organizationId];
222
+ const rows = await conn.execute(sql, params);
223
+ const records = Array.isArray(rows) ? rows.filter((row) => Boolean(row) && typeof row === "object") : [];
224
+ const map = this.applySystemDefault(this.pickMostSpecificMap(key, records), key.entityId);
225
+ const mapRules = normalizeEncryptedFieldRules(map?.fields);
226
+ if (!includeAllOrganizations) return { map, fields: mapRules };
227
+ const organizationRows = records.filter((record) => sameScopeId(readScopeId(record, "tenant_id", "tenantId"), key.tenantId) && readScopeId(record, "organization_id", "organizationId") !== null);
228
+ const organizationRules = mergeEncryptedFieldRules(
229
+ organizationRows.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record)))
230
+ );
231
+ return { map, fields: mergeEncryptedFieldRules([mapRules, organizationRules]) };
246
232
  }
247
- async getMap(key) {
248
- const shouldSkipLookup = (tag) => {
249
- const expiresAt = this.missCache.get(tag);
250
- if (!expiresAt) return false;
251
- if (expiresAt > Date.now()) return true;
252
- this.missCache.delete(tag);
253
- return false;
254
- };
255
- const recordMiss = (tag) => {
256
- this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS);
257
- };
233
+ pickMostSpecificMap(key, records) {
258
234
  const candidates = [
259
235
  key,
260
- { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },
236
+ { entityId: key.entityId, tenantId: key.tenantId, organizationId: null },
261
237
  { entityId: key.entityId, tenantId: null, organizationId: null }
262
238
  ];
263
239
  for (const candidate of candidates) {
264
- const tag = cacheKey(candidate);
265
- if (shouldSkipLookup(tag)) continue;
266
- if (this.inflightMaps.has(tag)) {
267
- const pending2 = this.inflightMaps.get(tag);
268
- const resolved = await pending2;
269
- if (resolved) return this.applySystemDefault(resolved, key.entityId);
270
- }
271
- const mem = this.memoryCache.get(tag);
272
- if (mem) return this.applySystemDefault(mem, key.entityId);
273
- if (this.cache && typeof this.cache.get === "function") {
274
- const cached = await this.cache.get(tag);
275
- if (cached) return this.applySystemDefault(cached, key.entityId);
276
- }
277
- const pending = this.fetchMap(candidate);
278
- this.inflightMaps.set(tag, pending);
279
- const loaded = await pending;
280
- this.inflightMaps.delete(tag);
281
- if (!loaded) {
282
- recordMiss(tag);
240
+ const matches = records.filter((record) => sameScopeId(readScopeId(record, "tenant_id", "tenantId"), candidate.tenantId) && sameScopeId(readScopeId(record, "organization_id", "organizationId"), candidate.organizationId));
241
+ if (!matches.length) {
283
242
  debug("\u{1F50D} encmap.miss", {
284
243
  entityId: candidate.entityId,
285
244
  tenantId: candidate.tenantId,
@@ -287,110 +246,72 @@ class TenantDataEncryptionService {
287
246
  });
288
247
  continue;
289
248
  }
290
- this.missCache.delete(tag);
291
- this.memoryCache.set(tag, loaded);
292
- if (this.cache && typeof this.cache.set === "function") {
293
- await this.cache.set(tag, loaded, { ttl: 300 });
249
+ if (matches.length > 1) {
250
+ logger.warn("Duplicate live encryption maps detected; merging field rules fail-safe", {
251
+ entityId: candidate.entityId,
252
+ tenantId: candidate.tenantId,
253
+ organizationId: candidate.organizationId,
254
+ count: matches.length
255
+ });
294
256
  }
295
- return this.applySystemDefault(loaded, key.entityId);
257
+ return {
258
+ entityId: String(matches[0].entity_id ?? matches[0].entityId ?? key.entityId),
259
+ fields: mergeEncryptedFieldRules(matches.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record))))
260
+ };
296
261
  }
297
- return this.applySystemDefault(null, key.entityId);
262
+ return null;
298
263
  }
299
- async fetchAllOrganizationFieldRules(entityId, tenantId) {
300
- const conn = getSqlConnection(this.em);
301
- if (!conn) return [];
302
- const sql = `
303
- select fields_json
304
- from encryption_maps
305
- where entity_id = ?
306
- and tenant_id is not distinct from ?
307
- and organization_id is not null
308
- and is_active = true
309
- and deleted_at is null
310
- `;
311
- const rows = await conn.execute(sql, [entityId, tenantId]);
312
- if (!Array.isArray(rows) || rows.length === 0) return [];
313
- const groups = [];
314
- for (const row of rows) {
315
- if (!row || typeof row !== "object") continue;
316
- groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row)));
264
+ applySystemDefault(record, entityId) {
265
+ const declared = this.systemDefaultMaps.get(entityId);
266
+ if (!declared) return record;
267
+ const fields = declared.fields.map((field) => ({
268
+ field: field.field,
269
+ hashField: field.hashField ?? null
270
+ }));
271
+ const declaredFields = new Set(fields.map((field) => field.field));
272
+ for (const field of record?.fields ?? []) {
273
+ if (!declaredFields.has(field.field)) fields.push(field);
317
274
  }
318
- return mergeEncryptedFieldRules(groups);
275
+ return {
276
+ entityId,
277
+ keyScope: "system",
278
+ fields
279
+ };
319
280
  }
320
281
  /**
321
- * Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`
322
- * and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached
323
- * read this used to be would add a round-trip per flush and per load (#5949).
282
+ * Resolve policy from the canonical table. Nothing is retained across units of work: no
283
+ * invalidation protocol can make process-local state authoritative after an independent worker
284
+ * commits a map mutation.
285
+ *
286
+ * Within one unit of work — the caller's EntityManager, or its open transaction — repeated
287
+ * decisions for the same scope share a single read, so loading N rows or running the before/after
288
+ * hooks of one flush costs one query instead of one per row and hook. That memo dies with the
289
+ * EntityManager/transaction, is dropped for the EntityManager that writes a map
290
+ * (`forgetEncryptionPolicyMemo`) and after its flush, and for every EntityManager when this
291
+ * process invalidates a map. Calls that do not hand in an EntityManager fall back to the
292
+ * long-lived service EntityManager and are never memoized.
324
293
  */
325
- async getAllOrganizationFieldRules(entityId, tenantId) {
326
- const tag = allOrganizationsCacheKey(entityId, tenantId);
327
- const missExpiresAt = this.missCache.get(tag);
328
- if (missExpiresAt) {
329
- if (missExpiresAt > Date.now()) return [];
330
- this.missCache.delete(tag);
331
- }
332
- const mem = this.aggregateMemoryCache.get(tag);
333
- if (mem) {
334
- if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields;
335
- this.aggregateMemoryCache.delete(tag);
336
- }
337
- if (this.cache && typeof this.cache.get === "function") {
338
- const cached = await this.cache.get(tag);
339
- if (cached) {
340
- const record = cached;
341
- this.aggregateMemoryCache.set(tag, { at: Date.now(), record });
342
- return record.fields;
343
- }
344
- }
345
- const inflight = this.inflightMaps.get(tag);
346
- if (inflight) return (await inflight)?.fields ?? [];
347
- const pending = (async () => {
348
- const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId);
349
- return fields.length ? { entityId, fields } : null;
350
- })();
351
- this.inflightMaps.set(tag, pending);
352
- let loaded;
353
- try {
354
- loaded = await pending;
355
- } finally {
356
- this.inflightMaps.delete(tag);
357
- }
358
- if (!loaded) {
359
- this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS);
360
- return [];
361
- }
362
- this.missCache.delete(tag);
363
- this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded });
364
- if (this.cache && typeof this.cache.set === "function") {
365
- await this.cache.set(tag, loaded, { ttl: 300 });
366
- }
367
- return loaded.fields;
294
+ resolvePolicy(key, em) {
295
+ if (!em) return this.loadPolicy(key);
296
+ const memo = getEncryptionPolicyMemo(em);
297
+ const tag = cacheKey(key);
298
+ const pending = memo.get(tag);
299
+ if (pending) return pending;
300
+ const loading = this.loadPolicy(key, em);
301
+ memo.set(tag, loading);
302
+ loading.catch(() => {
303
+ if (memo.get(tag) === loading) memo.delete(tag);
304
+ });
305
+ return loading;
368
306
  }
369
307
  /**
370
- * The field rules that are actually encrypted at rest for a scope.
371
- *
372
- * At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but
373
- * rows of the same entity may carry fields declared solely by an organization-scoped map. Reading
374
- * or writing such a row with the base map alone stores those fields as plaintext and hands
375
- * callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in
376
- * (#5949) — the same set `getEncryptedFieldNames` has reported since #2282.
308
+ * Drops the policy reads memoized by every EntityManager of this process. Map writers call it
309
+ * after their write commits; readers in other processes never depended on it, because policy is
310
+ * not cached across units of work.
377
311
  */
378
- async resolveFieldRulesForScope(entityId, tenantId, organizationId, map) {
379
- const mapRules = normalizeEncryptedFieldRules(map?.fields);
380
- if (organizationId != null) return mapRules;
381
- return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)]);
382
- }
383
312
  async invalidateMap(entityId, tenantId, organizationId) {
384
- const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)];
385
- for (const tag of tags) {
386
- this.memoryCache.delete(tag);
387
- this.aggregateMemoryCache.delete(tag);
388
- this.inflightMaps.delete(tag);
389
- this.missCache.delete(tag);
390
- if (this.cache && typeof this.cache.delete === "function") {
391
- await this.cache.delete(tag);
392
- }
393
- }
313
+ debug("\u267B\uFE0F encmap.invalidate", { entityId, tenantId, organizationId });
314
+ invalidateEncryptionPolicyMemos();
394
315
  }
395
316
  // Force a flush of a tenant's cached DEK across the service-level cache and the
396
317
  // underlying KMS cache so an operator can pick up a rotated/revoked key without
@@ -415,12 +336,9 @@ class TenantDataEncryptionService {
415
336
  } else if (!this.isEnabled()) {
416
337
  return [];
417
338
  }
418
- const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
419
- const fields = await this.resolveFieldRulesForScope(
420
- entityId,
421
- tenantId ?? null,
422
- organizationId ?? null,
423
- map
339
+ const { fields } = await this.resolvePolicy(
340
+ { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
341
+ options?.em
424
342
  );
425
343
  return fields.map((rule) => rule.field);
426
344
  }
@@ -481,12 +399,9 @@ class TenantDataEncryptionService {
481
399
  debug("\u26AA\uFE0F encrypt.skip.disabled", { entityId, tenantId });
482
400
  return payload;
483
401
  }
484
- const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
485
- const fields = await this.resolveFieldRulesForScope(
486
- entityId,
487
- tenantId ?? null,
488
- organizationId ?? null,
489
- map
402
+ const { map, fields } = await this.resolvePolicy(
403
+ { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
404
+ options?.em
490
405
  );
491
406
  if (!fields.length) {
492
407
  debug("\u26AA\uFE0F encrypt.skip.no-map", { entityId, tenantId });
@@ -501,17 +416,14 @@ class TenantDataEncryptionService {
501
416
  debug("\u{1F512} encrypt_entity", { entityId, tenantId, organizationId, fields: fields.length });
502
417
  return this.encryptFields(payload, fields, dek);
503
418
  }
504
- async decryptEntityPayload(entityId, payload, tenantId, organizationId) {
419
+ async decryptEntityPayload(entityId, payload, tenantId, organizationId, options) {
505
420
  if (!isTenantDataEncryptionEnabled()) {
506
421
  debug("\u26AA\uFE0F decrypt.skip.disabled", { entityId, tenantId });
507
422
  return payload;
508
423
  }
509
- const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
510
- const fields = await this.resolveFieldRulesForScope(
511
- entityId,
512
- tenantId ?? null,
513
- organizationId ?? null,
514
- map
424
+ const { map, fields } = await this.resolvePolicy(
425
+ { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
426
+ options?.em
515
427
  );
516
428
  if (!fields.length) {
517
429
  debug("\u26AA\uFE0F decrypt.skip.no-map", { entityId, tenantId });
@@ -529,6 +441,7 @@ class TenantDataEncryptionService {
529
441
  }
530
442
  export {
531
443
  TenantDataEncryptionService,
444
+ encryptionMapPolicyVersionCacheKey,
532
445
  parseDecryptedFieldValue,
533
446
  resolveEncryptionKeyId
534
447
  };
@@ -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 {\n TenantDataEncryptionError,\n TenantDataEncryptionErrorCode,\n decryptWithAesGcm,\n encryptWithAesGcm,\n hashForLookup,\n isEncryptedPayloadShape,\n} 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\n/**\n * Guard the encrypt path against re-wrapping ciphertext this process cannot open.\n *\n * Called only after {@link isEncryptedWithDek} has already said \"not sealed under the\n * current DEK\". At that point a structurally well-formed envelope means one of two things:\n * genuine ciphertext under some other key, or a length-valid forgery \u2014 the shape check is\n * length-based, not content-based, so any length-correct string qualifies, not just a\n * byte-exact match against something real. Both must stop the write \u2014 the first because\n * nesting envelopes silently destroys recoverable data, the second because rejecting it is\n * strictly safer than persisting attacker-chosen bytes.\n *\n * The field name is safe to report (it comes from the encryption map, not user input); the\n * value never is, so it stays out of both the error message and the log.\n */\nfunction assertNotSealedUnderAnotherKey(value: unknown, field: string): void {\n if (!isEncryptedPayloadShape(value)) return\n logger.error('Refusing to re-encrypt a value sealed under a different key', { field })\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.WRONG_KEY,\n `[internal] Field \"${field}\" already holds an encrypted payload that does not decrypt under the current tenant DEK. `\n + 'Encrypting it again would produce an unreadable nested envelope. '\n + 'Complete the key rotation for this tenant (mercato entities rotate-encryption-key --old-key \u2026) '\n + 'or restore the DEK that sealed it before writing this record again.',\n )\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 // Failing that check does not prove the value is plaintext. A well-formed\n // envelope sealed under a *different* key \u2014 the previous DEK mid-rotation, or\n // the derived key the KMS falls back to during a Vault outage \u2014 lands here too,\n // and encrypting it again would nest one envelope inside another: unreadable by\n // any normal decrypt, indistinguishable from correct ciphertext by inspection,\n // and it would overwrite the lookup hash with a hash of ciphertext (issue #5951).\n // Fail the write closed instead. Nothing is ever stored verbatim, so #2720 stays\n // shut: a forgery whose shape is not length-valid still gets encrypted as plaintext\n // above, and a length-valid one is rejected rather than persisted.\n assertNotSealedUnderAnotherKey(value, rule.field)\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
- "names": ["pending"]
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport {\n TenantDataEncryptionError,\n TenantDataEncryptionErrorCode,\n decryptWithAesGcm,\n encryptWithAesGcm,\n hashForLookup,\n isEncryptedPayloadShape,\n} from './aes'\nimport { createKmsService, type KmsService, type TenantDek } from './kms'\nimport { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\nimport {\n getEncryptionPolicyMemo,\n invalidateEncryptionPolicyMemos,\n type ResolvedEncryptionPolicy,\n} from './policyMemo'\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\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 readScopeId(row: Record<string, unknown>, column: string, property: string): string | null {\n const value = row[column] ?? row[property]\n return value == null ? null : String(value).toLowerCase()\n}\n\nfunction sameScopeId(value: string | null, expected: string | null): boolean {\n return value === (expected == null ? null : expected.toLowerCase())\n}\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/**\n * @deprecated Nothing reads this key: encryption policy is resolved from the canonical table and\n * `invalidateMap` no longer publishes a version token. Kept for import compatibility.\n */\nexport function encryptionMapPolicyVersionCacheKey(entityId: string): string {\n return ['encmap-policy-version', entityId.toLowerCase()].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\n/**\n * Guard the encrypt path against re-wrapping ciphertext this process cannot open.\n *\n * Called only after {@link isEncryptedWithDek} has already said \"not sealed under the\n * current DEK\". At that point a structurally well-formed envelope means one of two things:\n * genuine ciphertext under some other key, or a length-valid forgery \u2014 the shape check is\n * length-based, not content-based, so any length-correct string qualifies, not just a\n * byte-exact match against something real. Both must stop the write \u2014 the first because\n * nesting envelopes silently destroys recoverable data, the second because rejecting it is\n * strictly safer than persisting attacker-chosen bytes.\n *\n * The field name is safe to report (it comes from the encryption map, not user input); the\n * value never is, so it stays out of both the error message and the log.\n */\nfunction assertNotSealedUnderAnotherKey(value: unknown, field: string): void {\n if (!isEncryptedPayloadShape(value)) return\n logger.error('Refusing to re-encrypt a value sealed under a different key', { field })\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.WRONG_KEY,\n `[internal] Field \"${field}\" already holds an encrypted payload that does not decrypt under the current tenant DEK. `\n + 'Encrypting it again would produce an unreadable nested envelope. '\n + 'Complete the key rotation for this tenant (mercato entities rotate-encryption-key --old-key \u2026) '\n + 'or restore the DEK that sealed it before writing this record again.',\n )\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\n/**\n * Runs a map lookup on an EntityManager rather than on `em.getConnection()`.\n *\n * `AbstractSqlConnection.execute` falls back to the pool whenever it is handed no\n * transaction context, so the connection-level call takes a SECOND pooled\n * connection while the caller already holds one for its open write transaction.\n * Policy resolution is not cached across units of work and runs for every\n * encrypt/decrypt decision that misses the per-unit-of-work memo, so under\n * concurrency every in-flight write waits on a connection that only another\n * waiting write could release \u2014 the pool deadlocks at `DB_POOL_MAX` and never\n * recovers. `SqlEntityManager.execute` forwards `getTransactionContext()`, so the\n * lookup reuses the caller's own connection, provided callers hand in the\n * EntityManager that owns the transaction (the flush subscriber passes\n * `args.em`). Reading the policy inside that transaction is also what an atomic,\n * transaction-aware map write needs.\n */\nfunction getSqlExecutor(em: EntityManager): SqlConnection | null {\n const candidate = em as unknown as { execute?: unknown }\n if (typeof candidate.execute !== 'function') return null\n return candidate as unknown as SqlConnection\n}\n\nexport class TenantDataEncryptionService {\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()\n private readonly kms: KmsService\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks\n private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>\n\n constructor(\n private em: EntityManager,\n opts?: {\n /** Accepted for compatibility; encryption policy is never cached across units of work. */\n cache?: CacheStrategy\n kms?: KmsService\n defaultEncryptionMaps?: readonly ModuleEncryptionMap[]\n }\n ) {\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 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 /**\n * Reads every row that can decide the policy for `key` in ONE statement: the exact scope, the\n * tenant-wide scope, and the global scope \u2014 plus, at the tenant-wide scope, every\n * organization-scoped map of the tenant for the all-organizations aggregate (#5949). Precedence\n * and duplicate merging happen in JS so the result is identical to resolving the fallback chain\n * one scope at a time.\n */\n private async loadPolicy(key: MapCacheKey, em?: EntityManager): Promise<ResolvedEncryptionPolicy> {\n // Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.\n const conn = getSqlExecutor(em ?? this.em)\n if (!conn) {\n throw new Error('[internal] Encryption policy lookup requires an EntityManager that can execute SQL')\n }\n const includeAllOrganizations = key.organizationId == null\n const scopeClause = includeAllOrganizations\n ? 'tenant_id is not distinct from ?'\n : '(tenant_id is not distinct from ? and (organization_id = ? or organization_id is null))'\n const sql = `\n select entity_id, tenant_id, organization_id, fields_json\n from encryption_maps\n where entity_id = ?\n and is_active = true\n and deleted_at is null\n and (${scopeClause} or (tenant_id is null and organization_id is null))\n order by organization_id asc, created_at asc, id asc\n `\n const params = includeAllOrganizations\n ? [key.entityId, key.tenantId]\n : [key.entityId, key.tenantId, key.organizationId]\n const rows = await conn.execute(sql, params)\n const records = Array.isArray(rows)\n ? rows.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === 'object')\n : []\n\n const map = this.applySystemDefault(this.pickMostSpecificMap(key, records), key.entityId)\n const mapRules = normalizeEncryptedFieldRules(map?.fields)\n if (!includeAllOrganizations) return { map, fields: mapRules }\n const organizationRows = records.filter((record) => (\n sameScopeId(readScopeId(record, 'tenant_id', 'tenantId'), key.tenantId)\n && readScopeId(record, 'organization_id', 'organizationId') !== null\n ))\n const organizationRules = mergeEncryptedFieldRules(\n organizationRows.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record))),\n )\n return { map, fields: mergeEncryptedFieldRules([mapRules, organizationRules]) }\n }\n\n private pickMostSpecificMap(key: MapCacheKey, records: Record<string, unknown>[]): EncryptionMapRecord | null {\n const candidates: MapCacheKey[] = [\n key,\n { entityId: key.entityId, tenantId: key.tenantId, organizationId: null },\n { entityId: key.entityId, tenantId: null, organizationId: null },\n ]\n for (const candidate of candidates) {\n const matches = records.filter((record) => (\n sameScopeId(readScopeId(record, 'tenant_id', 'tenantId'), candidate.tenantId)\n && sameScopeId(readScopeId(record, 'organization_id', 'organizationId'), candidate.organizationId)\n ))\n if (!matches.length) {\n debug('\uD83D\uDD0D encmap.miss', {\n entityId: candidate.entityId,\n tenantId: candidate.tenantId,\n organizationId: candidate.organizationId,\n })\n continue\n }\n if (matches.length > 1) {\n logger.warn('Duplicate live encryption maps detected; merging field rules fail-safe', {\n entityId: candidate.entityId,\n tenantId: candidate.tenantId,\n organizationId: candidate.organizationId,\n count: matches.length,\n })\n }\n return {\n entityId: String(matches[0].entity_id ?? matches[0].entityId ?? key.entityId),\n fields: mergeEncryptedFieldRules(matches.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record)))),\n }\n }\n return null\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 /**\n * Resolve policy from the canonical table. Nothing is retained across units of work: no\n * invalidation protocol can make process-local state authoritative after an independent worker\n * commits a map mutation.\n *\n * Within one unit of work \u2014 the caller's EntityManager, or its open transaction \u2014 repeated\n * decisions for the same scope share a single read, so loading N rows or running the before/after\n * hooks of one flush costs one query instead of one per row and hook. That memo dies with the\n * EntityManager/transaction, is dropped for the EntityManager that writes a map\n * (`forgetEncryptionPolicyMemo`) and after its flush, and for every EntityManager when this\n * process invalidates a map. Calls that do not hand in an EntityManager fall back to the\n * long-lived service EntityManager and are never memoized.\n */\n private resolvePolicy(key: MapCacheKey, em?: EntityManager): Promise<ResolvedEncryptionPolicy> {\n if (!em) return this.loadPolicy(key)\n const memo = getEncryptionPolicyMemo(em)\n const tag = cacheKey(key)\n const pending = memo.get(tag)\n if (pending) return pending\n const loading = this.loadPolicy(key, em)\n memo.set(tag, loading)\n loading.catch(() => {\n if (memo.get(tag) === loading) memo.delete(tag)\n })\n return loading\n }\n\n /**\n * Drops the policy reads memoized by every EntityManager of this process. Map writers call it\n * after their write commits; readers in other processes never depended on it, because policy is\n * not cached across units of work.\n */\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n debug('\u267B\uFE0F encmap.invalidate', { entityId, tenantId, organizationId })\n invalidateEncryptionPolicyMemos()\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; em?: EntityManager }\n ): Promise<string[]> {\n if (options?.ignoreRuntimeHealth) {\n if (!isTenantDataEncryptionEnabled()) return []\n } else if (!this.isEnabled()) {\n return []\n }\n const { fields } = await this.resolvePolicy(\n { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },\n options?.em,\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 // Failing that check does not prove the value is plaintext. A well-formed\n // envelope sealed under a *different* key \u2014 the previous DEK mid-rotation, or\n // the derived key the KMS falls back to during a Vault outage \u2014 lands here too,\n // and encrypting it again would nest one envelope inside another: unreadable by\n // any normal decrypt, indistinguishable from correct ciphertext by inspection,\n // and it would overwrite the lookup hash with a hash of ciphertext (issue #5951).\n // Fail the write closed instead. Nothing is ever stored verbatim, so #2720 stays\n // shut: a forgery whose shape is not length-valid still gets encrypted as plaintext\n // above, and a length-valid one is rejected rather than persisted.\n assertNotSealedUnderAnotherKey(value, rule.field)\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; em?: EntityManager }\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, fields } = await this.resolvePolicy(\n { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },\n options?.em,\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 options?: { em?: EntityManager }\n ): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled()) {\n debug('\u26AA\uFE0F decrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const { map, fields } = await this.resolvePolicy(\n { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },\n options?.em,\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
+ "names": []
7
7
  }