@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +12 -5
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- package/src/modules/registry.ts +4 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { EncryptedFieldRule, EncryptionMapRecord } from './tenantDataEncryptionService'
|
|
3
|
+
|
|
4
|
+
export type ResolvedEncryptionPolicy = {
|
|
5
|
+
map: EncryptionMapRecord | null
|
|
6
|
+
fields: EncryptedFieldRule[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type PolicyMemo = {
|
|
10
|
+
generation: number
|
|
11
|
+
entries: Map<string, Promise<ResolvedEncryptionPolicy>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const policyMemos = new WeakMap<object, PolicyMemo>()
|
|
15
|
+
let policyMemoGeneration = 0
|
|
16
|
+
|
|
17
|
+
function resolvePolicyMemoOwners(em: EntityManager): object[] {
|
|
18
|
+
const candidate = em as unknown as { getTransactionContext?: () => unknown }
|
|
19
|
+
const context = typeof candidate.getTransactionContext === 'function'
|
|
20
|
+
? candidate.getTransactionContext()
|
|
21
|
+
: undefined
|
|
22
|
+
return context && typeof context === 'object' ? [context, em] : [em]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The encryption-policy reads of one unit of work: keyed by the EntityManager's open transaction
|
|
27
|
+
* when it has one, otherwise by the EntityManager itself, so the memo can never outlive either.
|
|
28
|
+
*/
|
|
29
|
+
export function getEncryptionPolicyMemo(em: EntityManager): Map<string, Promise<ResolvedEncryptionPolicy>> {
|
|
30
|
+
const [owner] = resolvePolicyMemoOwners(em)
|
|
31
|
+
const existing = policyMemos.get(owner)
|
|
32
|
+
if (existing && existing.generation === policyMemoGeneration) return existing.entries
|
|
33
|
+
const memo: PolicyMemo = { generation: policyMemoGeneration, entries: new Map() }
|
|
34
|
+
policyMemos.set(owner, memo)
|
|
35
|
+
return memo.entries
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Retire every policy memo of this process; called after an encryption map is invalidated. */
|
|
39
|
+
export function invalidateEncryptionPolicyMemos(): void {
|
|
40
|
+
policyMemoGeneration += 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Forget the encryption-policy reads memoized for an EntityManager (and its open transaction).
|
|
45
|
+
* Encryption-map writers call it right after writing through that EntityManager so later
|
|
46
|
+
* encrypt/decrypt decisions in the same unit of work see the new policy.
|
|
47
|
+
*/
|
|
48
|
+
export function forgetEncryptionPolicyMemo(em: EntityManager | null | undefined): void {
|
|
49
|
+
if (!em) return
|
|
50
|
+
for (const owner of resolvePolicyMemoOwners(em)) policyMemos.delete(owner)
|
|
51
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'
|
|
2
|
-
import {
|
|
1
|
+
import type { EntityMetadata, EventArgs, EventSubscriber, FlushEventArgs } from '@mikro-orm/core'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { DriverException, ReferenceKind } from '@mikro-orm/core'
|
|
3
4
|
import { resolveEntityIdFromMetadata } from './entityIds'
|
|
4
5
|
import { TenantDataEncryptionService, parseDecryptedFieldValue } from './tenantDataEncryptionService'
|
|
6
|
+
import { forgetEncryptionPolicyMemo } from './policyMemo'
|
|
5
7
|
import { isTenantDataEncryptionEnabled } from './toggles'
|
|
6
8
|
import { isEncryptionDebugEnabled } from './toggles'
|
|
7
9
|
import { resolveTenantEncryptionService } from './customFieldValues'
|
|
@@ -90,6 +92,26 @@ function getSubscriberForService(service: TenantDataEncryptionService): TenantEn
|
|
|
90
92
|
const toSnakeCase = (value: string): string =>
|
|
91
93
|
value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()
|
|
92
94
|
|
|
95
|
+
function isDatabaseError(err: unknown): boolean {
|
|
96
|
+
if (err instanceof DriverException) return true
|
|
97
|
+
if (!err || typeof err !== 'object') return false
|
|
98
|
+
const candidate = err as { code?: unknown; severity?: unknown; sqlState?: unknown }
|
|
99
|
+
if (typeof candidate.sqlState === 'string') return true
|
|
100
|
+
return typeof candidate.code === 'string' && typeof candidate.severity === 'string'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isInTransaction(em: unknown): boolean {
|
|
104
|
+
if (!em || typeof em !== 'object') return false
|
|
105
|
+
const candidate = em as { isInTransaction?: () => boolean; getTransactionContext?: () => unknown }
|
|
106
|
+
try {
|
|
107
|
+
if (typeof candidate.isInTransaction === 'function') return candidate.isInTransaction()
|
|
108
|
+
if (typeof candidate.getTransactionContext === 'function') return candidate.getTransactionContext() != null
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
94
116
|
constructor(private readonly service: TenantDataEncryptionService) {}
|
|
95
117
|
|
|
@@ -247,7 +269,13 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
247
269
|
return
|
|
248
270
|
}
|
|
249
271
|
const { tenantId, organizationId } = resolveScope(target)
|
|
250
|
-
|
|
272
|
+
// Hand the flushing EntityManager to the policy read so it runs on this
|
|
273
|
+
// transaction's own connection instead of acquiring a second pooled one
|
|
274
|
+
// (an uncached lookup from inside an open write transaction deadlocks the
|
|
275
|
+
// pool once every slot is held by such a transaction).
|
|
276
|
+
const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId, {
|
|
277
|
+
em: em as EntityManager | undefined,
|
|
278
|
+
})
|
|
251
279
|
const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'
|
|
252
280
|
? resolvedMeta.properties
|
|
253
281
|
: {}
|
|
@@ -349,7 +377,9 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
349
377
|
// drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
|
|
350
378
|
// encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).
|
|
351
379
|
const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false
|
|
352
|
-
const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId
|
|
380
|
+
const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId, {
|
|
381
|
+
em: em as EntityManager | undefined,
|
|
382
|
+
})
|
|
353
383
|
Object.assign(target, decrypted)
|
|
354
384
|
this.restoreDecryptedJsonColumns(target, resolvedMeta)
|
|
355
385
|
if (syncOriginal && !hadPendingChanges) {
|
|
@@ -426,6 +456,9 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
426
456
|
}
|
|
427
457
|
}
|
|
428
458
|
} catch (err) {
|
|
459
|
+
// A failed statement aborts the surrounding Postgres transaction; swallowing it here would
|
|
460
|
+
// only move the failure to the caller's next statement ("current transaction is aborted").
|
|
461
|
+
if (isDatabaseError(err) && isInTransaction(em)) throw err
|
|
429
462
|
debug('⚠️ subscriber.deep_decrypt.error', {
|
|
430
463
|
entityId,
|
|
431
464
|
message: (err as Error)?.message ?? String(err),
|
|
@@ -458,6 +491,10 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
458
491
|
await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })
|
|
459
492
|
}
|
|
460
493
|
|
|
494
|
+
afterFlush(args: FlushEventArgs) {
|
|
495
|
+
forgetEncryptionPolicyMemo(args.em as unknown as EntityManager)
|
|
496
|
+
}
|
|
497
|
+
|
|
461
498
|
async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {
|
|
462
499
|
const entities = Array.isArray(args.entities) ? args.entities : []
|
|
463
500
|
for (const entity of entities) {
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
import { createKmsService, type KmsService, type TenantDek } from './kms'
|
|
12
12
|
import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'
|
|
13
13
|
import { createLogger } from '../logger'
|
|
14
|
+
import {
|
|
15
|
+
getEncryptionPolicyMemo,
|
|
16
|
+
invalidateEncryptionPolicyMemos,
|
|
17
|
+
type ResolvedEncryptionPolicy,
|
|
18
|
+
} from './policyMemo'
|
|
14
19
|
import type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'
|
|
15
20
|
|
|
16
21
|
const logger = createLogger('shared').child({ component: 'tenant-encryption' })
|
|
@@ -36,15 +41,19 @@ type SqlConnection = {
|
|
|
36
41
|
execute(sql: string, params?: readonly unknown[]): Promise<unknown>
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
const MAP_MISS_TTL_MS = 5 * 60 * 1000
|
|
40
44
|
// Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked
|
|
41
45
|
// up by long-lived processes without a restart (#2746). The service-level cache
|
|
42
46
|
// previously had no TTL and shadowed the KMS's own 15-minute expiry.
|
|
43
47
|
const DEK_CACHE_TTL_MS = 15 * 60 * 1000
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
function readScopeId(row: Record<string, unknown>, column: string, property: string): string | null {
|
|
50
|
+
const value = row[column] ?? row[property]
|
|
51
|
+
return value == null ? null : String(value).toLowerCase()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameScopeId(value: string | null, expected: string | null): boolean {
|
|
55
|
+
return value === (expected == null ? null : expected.toLowerCase())
|
|
56
|
+
}
|
|
48
57
|
|
|
49
58
|
function cacheKey(key: MapCacheKey): string {
|
|
50
59
|
return [
|
|
@@ -55,11 +64,12 @@ function cacheKey(key: MapCacheKey): string {
|
|
|
55
64
|
].join(':')
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
/**
|
|
68
|
+
* @deprecated Nothing reads this key: encryption policy is resolved from the canonical table and
|
|
69
|
+
* `invalidateMap` no longer publishes a version token. Kept for import compatibility.
|
|
70
|
+
*/
|
|
71
|
+
export function encryptionMapPolicyVersionCacheKey(entityId: string): string {
|
|
72
|
+
return ['encmap-policy-version', entityId.toLowerCase()].join(':')
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
function debug(event: string, payload: Record<string, unknown>) {
|
|
@@ -227,41 +237,45 @@ export function resolveEncryptionKeyId(
|
|
|
227
237
|
return keyScope === 'system' ? `system:${entityId}` : tenantId ?? null
|
|
228
238
|
}
|
|
229
239
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Runs a map lookup on an EntityManager rather than on `em.getConnection()`.
|
|
242
|
+
*
|
|
243
|
+
* `AbstractSqlConnection.execute` falls back to the pool whenever it is handed no
|
|
244
|
+
* transaction context, so the connection-level call takes a SECOND pooled
|
|
245
|
+
* connection while the caller already holds one for its open write transaction.
|
|
246
|
+
* Policy resolution is not cached across units of work and runs for every
|
|
247
|
+
* encrypt/decrypt decision that misses the per-unit-of-work memo, so under
|
|
248
|
+
* concurrency every in-flight write waits on a connection that only another
|
|
249
|
+
* waiting write could release — the pool deadlocks at `DB_POOL_MAX` and never
|
|
250
|
+
* recovers. `SqlEntityManager.execute` forwards `getTransactionContext()`, so the
|
|
251
|
+
* lookup reuses the caller's own connection, provided callers hand in the
|
|
252
|
+
* EntityManager that owns the transaction (the flush subscriber passes
|
|
253
|
+
* `args.em`). Reading the policy inside that transaction is also what an atomic,
|
|
254
|
+
* transaction-aware map write needs.
|
|
255
|
+
*/
|
|
256
|
+
function getSqlExecutor(em: EntityManager): SqlConnection | null {
|
|
257
|
+
const candidate = em as unknown as { execute?: unknown }
|
|
235
258
|
if (typeof candidate.execute !== 'function') return null
|
|
236
|
-
return candidate as SqlConnection
|
|
259
|
+
return candidate as unknown as SqlConnection
|
|
237
260
|
}
|
|
238
261
|
|
|
239
262
|
export class TenantDataEncryptionService {
|
|
240
|
-
private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
|
|
241
|
-
private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()
|
|
242
|
-
private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
|
|
243
263
|
private static globalDekCache = new Map<string, TenantDek>()
|
|
244
264
|
private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()
|
|
245
|
-
private static globalMissCache = new Map<string, number>()
|
|
246
265
|
private readonly kms: KmsService
|
|
247
|
-
private readonly cache?: CacheStrategy
|
|
248
|
-
private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
|
|
249
|
-
private readonly aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache
|
|
250
266
|
private readonly dekCache = TenantDataEncryptionService.globalDekCache
|
|
251
267
|
private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
|
|
252
|
-
private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
|
|
253
|
-
private readonly missCache = TenantDataEncryptionService.globalMissCache
|
|
254
268
|
private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>
|
|
255
269
|
|
|
256
270
|
constructor(
|
|
257
271
|
private em: EntityManager,
|
|
258
272
|
opts?: {
|
|
273
|
+
/** Accepted for compatibility; encryption policy is never cached across units of work. */
|
|
259
274
|
cache?: CacheStrategy
|
|
260
275
|
kms?: KmsService
|
|
261
276
|
defaultEncryptionMaps?: readonly ModuleEncryptionMap[]
|
|
262
277
|
}
|
|
263
278
|
) {
|
|
264
|
-
this.cache = opts?.cache
|
|
265
279
|
this.kms = opts?.kms ?? createKmsService()
|
|
266
280
|
this.systemDefaultMaps = new Map(
|
|
267
281
|
(opts?.defaultEncryptionMaps ?? [])
|
|
@@ -313,7 +327,6 @@ export class TenantDataEncryptionService {
|
|
|
313
327
|
if (typeof this.kms.createTenantDek !== 'function') return existing ?? null
|
|
314
328
|
// Dedupe concurrent first-time creation within this process so two callers
|
|
315
329
|
// can't each generate a distinct DEK and overwrite one another (#2746).
|
|
316
|
-
// Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).
|
|
317
330
|
const pending = this.inflightDeks.get(tenantId)
|
|
318
331
|
if (pending) return pending
|
|
319
332
|
const creation = (async () => {
|
|
@@ -335,86 +348,65 @@ export class TenantDataEncryptionService {
|
|
|
335
348
|
return dek
|
|
336
349
|
}
|
|
337
350
|
|
|
338
|
-
|
|
351
|
+
/**
|
|
352
|
+
* Reads every row that can decide the policy for `key` in ONE statement: the exact scope, the
|
|
353
|
+
* tenant-wide scope, and the global scope — plus, at the tenant-wide scope, every
|
|
354
|
+
* organization-scoped map of the tenant for the all-organizations aggregate (#5949). Precedence
|
|
355
|
+
* and duplicate merging happen in JS so the result is identical to resolving the fallback chain
|
|
356
|
+
* one scope at a time.
|
|
357
|
+
*/
|
|
358
|
+
private async loadPolicy(key: MapCacheKey, em?: EntityManager): Promise<ResolvedEncryptionPolicy> {
|
|
339
359
|
// Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.
|
|
340
|
-
const conn =
|
|
341
|
-
if (!conn)
|
|
360
|
+
const conn = getSqlExecutor(em ?? this.em)
|
|
361
|
+
if (!conn) {
|
|
362
|
+
throw new Error('[internal] Encryption policy lookup requires an EntityManager that can execute SQL')
|
|
363
|
+
}
|
|
364
|
+
const includeAllOrganizations = key.organizationId == null
|
|
365
|
+
const scopeClause = includeAllOrganizations
|
|
366
|
+
? 'tenant_id is not distinct from ?'
|
|
367
|
+
: '(tenant_id is not distinct from ? and (organization_id = ? or organization_id is null))'
|
|
342
368
|
const sql = `
|
|
343
|
-
select entity_id, fields_json
|
|
369
|
+
select entity_id, tenant_id, organization_id, fields_json
|
|
344
370
|
from encryption_maps
|
|
345
371
|
where entity_id = ?
|
|
346
|
-
and tenant_id is not distinct from ?
|
|
347
|
-
and organization_id is not distinct from ?
|
|
348
372
|
and is_active = true
|
|
349
373
|
and deleted_at is null
|
|
350
|
-
|
|
374
|
+
and (${scopeClause} or (tenant_id is null and organization_id is null))
|
|
375
|
+
order by organization_id asc, created_at asc, id asc
|
|
351
376
|
`
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
for (const field of record?.fields ?? []) {
|
|
372
|
-
if (!declaredFields.has(field.field)) fields.push(field)
|
|
373
|
-
}
|
|
374
|
-
return {
|
|
375
|
-
entityId,
|
|
376
|
-
keyScope: 'system',
|
|
377
|
-
fields,
|
|
378
|
-
}
|
|
377
|
+
const params = includeAllOrganizations
|
|
378
|
+
? [key.entityId, key.tenantId]
|
|
379
|
+
: [key.entityId, key.tenantId, key.organizationId]
|
|
380
|
+
const rows = await conn.execute(sql, params)
|
|
381
|
+
const records = Array.isArray(rows)
|
|
382
|
+
? rows.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === 'object')
|
|
383
|
+
: []
|
|
384
|
+
|
|
385
|
+
const map = this.applySystemDefault(this.pickMostSpecificMap(key, records), key.entityId)
|
|
386
|
+
const mapRules = normalizeEncryptedFieldRules(map?.fields)
|
|
387
|
+
if (!includeAllOrganizations) return { map, fields: mapRules }
|
|
388
|
+
const organizationRows = records.filter((record) => (
|
|
389
|
+
sameScopeId(readScopeId(record, 'tenant_id', 'tenantId'), key.tenantId)
|
|
390
|
+
&& readScopeId(record, 'organization_id', 'organizationId') !== null
|
|
391
|
+
))
|
|
392
|
+
const organizationRules = mergeEncryptedFieldRules(
|
|
393
|
+
organizationRows.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record))),
|
|
394
|
+
)
|
|
395
|
+
return { map, fields: mergeEncryptedFieldRules([mapRules, organizationRules]) }
|
|
379
396
|
}
|
|
380
397
|
|
|
381
|
-
private
|
|
382
|
-
const shouldSkipLookup = (tag: string) => {
|
|
383
|
-
const expiresAt = this.missCache.get(tag)
|
|
384
|
-
if (!expiresAt) return false
|
|
385
|
-
if (expiresAt > Date.now()) return true
|
|
386
|
-
this.missCache.delete(tag)
|
|
387
|
-
return false
|
|
388
|
-
}
|
|
389
|
-
const recordMiss = (tag: string) => {
|
|
390
|
-
this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
|
|
391
|
-
}
|
|
392
|
-
|
|
398
|
+
private pickMostSpecificMap(key: MapCacheKey, records: Record<string, unknown>[]): EncryptionMapRecord | null {
|
|
393
399
|
const candidates: MapCacheKey[] = [
|
|
394
400
|
key,
|
|
395
|
-
{ entityId: key.entityId, tenantId: key.tenantId
|
|
401
|
+
{ entityId: key.entityId, tenantId: key.tenantId, organizationId: null },
|
|
396
402
|
{ entityId: key.entityId, tenantId: null, organizationId: null },
|
|
397
403
|
]
|
|
398
404
|
for (const candidate of candidates) {
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (resolved) return this.applySystemDefault(resolved, key.entityId)
|
|
405
|
-
}
|
|
406
|
-
const mem = this.memoryCache.get(tag)
|
|
407
|
-
if (mem) return this.applySystemDefault(mem, key.entityId)
|
|
408
|
-
if (this.cache && typeof this.cache.get === 'function') {
|
|
409
|
-
const cached = await this.cache.get(tag)
|
|
410
|
-
if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)
|
|
411
|
-
}
|
|
412
|
-
const pending = this.fetchMap(candidate)
|
|
413
|
-
this.inflightMaps.set(tag, pending)
|
|
414
|
-
const loaded = await pending
|
|
415
|
-
this.inflightMaps.delete(tag)
|
|
416
|
-
if (!loaded) {
|
|
417
|
-
recordMiss(tag)
|
|
405
|
+
const matches = records.filter((record) => (
|
|
406
|
+
sameScopeId(readScopeId(record, 'tenant_id', 'tenantId'), candidate.tenantId)
|
|
407
|
+
&& sameScopeId(readScopeId(record, 'organization_id', 'organizationId'), candidate.organizationId)
|
|
408
|
+
))
|
|
409
|
+
if (!matches.length) {
|
|
418
410
|
debug('🔍 encmap.miss', {
|
|
419
411
|
entityId: candidate.entityId,
|
|
420
412
|
tenantId: candidate.tenantId,
|
|
@@ -422,125 +414,75 @@ export class TenantDataEncryptionService {
|
|
|
422
414
|
})
|
|
423
415
|
continue
|
|
424
416
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
417
|
+
if (matches.length > 1) {
|
|
418
|
+
logger.warn('Duplicate live encryption maps detected; merging field rules fail-safe', {
|
|
419
|
+
entityId: candidate.entityId,
|
|
420
|
+
tenantId: candidate.tenantId,
|
|
421
|
+
organizationId: candidate.organizationId,
|
|
422
|
+
count: matches.length,
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
entityId: String(matches[0].entity_id ?? matches[0].entityId ?? key.entityId),
|
|
427
|
+
fields: mergeEncryptedFieldRules(matches.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record)))),
|
|
429
428
|
}
|
|
430
|
-
return this.applySystemDefault(loaded, key.entityId)
|
|
431
|
-
}
|
|
432
|
-
return this.applySystemDefault(null, key.entityId)
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
private async fetchAllOrganizationFieldRules(
|
|
436
|
-
entityId: string,
|
|
437
|
-
tenantId: string | null,
|
|
438
|
-
): Promise<EncryptedFieldRule[]> {
|
|
439
|
-
const conn = getSqlConnection(this.em)
|
|
440
|
-
if (!conn) return []
|
|
441
|
-
const sql = `
|
|
442
|
-
select fields_json
|
|
443
|
-
from encryption_maps
|
|
444
|
-
where entity_id = ?
|
|
445
|
-
and tenant_id is not distinct from ?
|
|
446
|
-
and organization_id is not null
|
|
447
|
-
and is_active = true
|
|
448
|
-
and deleted_at is null
|
|
449
|
-
`
|
|
450
|
-
const rows = await conn.execute(sql, [entityId, tenantId])
|
|
451
|
-
if (!Array.isArray(rows) || rows.length === 0) return []
|
|
452
|
-
const groups: EncryptedFieldRule[][] = []
|
|
453
|
-
for (const row of rows) {
|
|
454
|
-
if (!row || typeof row !== 'object') continue
|
|
455
|
-
groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))
|
|
456
429
|
}
|
|
457
|
-
return
|
|
430
|
+
return null
|
|
458
431
|
}
|
|
459
432
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const missExpiresAt = this.missCache.get(tag)
|
|
471
|
-
if (missExpiresAt) {
|
|
472
|
-
if (missExpiresAt > Date.now()) return []
|
|
473
|
-
this.missCache.delete(tag)
|
|
474
|
-
}
|
|
475
|
-
const mem = this.aggregateMemoryCache.get(tag)
|
|
476
|
-
if (mem) {
|
|
477
|
-
if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields
|
|
478
|
-
this.aggregateMemoryCache.delete(tag)
|
|
479
|
-
}
|
|
480
|
-
if (this.cache && typeof this.cache.get === 'function') {
|
|
481
|
-
const cached = await this.cache.get(tag)
|
|
482
|
-
if (cached) {
|
|
483
|
-
const record = cached as EncryptionMapRecord
|
|
484
|
-
this.aggregateMemoryCache.set(tag, { at: Date.now(), record })
|
|
485
|
-
return record.fields
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
const inflight = this.inflightMaps.get(tag)
|
|
489
|
-
if (inflight) return (await inflight)?.fields ?? []
|
|
490
|
-
const pending = (async (): Promise<EncryptionMapRecord | null> => {
|
|
491
|
-
const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)
|
|
492
|
-
return fields.length ? { entityId, fields } : null
|
|
493
|
-
})()
|
|
494
|
-
this.inflightMaps.set(tag, pending)
|
|
495
|
-
let loaded: EncryptionMapRecord | null
|
|
496
|
-
try {
|
|
497
|
-
loaded = await pending
|
|
498
|
-
} finally {
|
|
499
|
-
this.inflightMaps.delete(tag)
|
|
500
|
-
}
|
|
501
|
-
if (!loaded) {
|
|
502
|
-
this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
|
|
503
|
-
return []
|
|
433
|
+
private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {
|
|
434
|
+
const declared = this.systemDefaultMaps.get(entityId)
|
|
435
|
+
if (!declared) return record
|
|
436
|
+
const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({
|
|
437
|
+
field: field.field,
|
|
438
|
+
hashField: field.hashField ?? null,
|
|
439
|
+
}))
|
|
440
|
+
const declaredFields = new Set(fields.map((field) => field.field))
|
|
441
|
+
for (const field of record?.fields ?? []) {
|
|
442
|
+
if (!declaredFields.has(field.field)) fields.push(field)
|
|
504
443
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
444
|
+
return {
|
|
445
|
+
entityId,
|
|
446
|
+
keyScope: 'system',
|
|
447
|
+
fields,
|
|
509
448
|
}
|
|
510
|
-
return loaded.fields
|
|
511
449
|
}
|
|
512
450
|
|
|
513
451
|
/**
|
|
514
|
-
*
|
|
452
|
+
* Resolve policy from the canonical table. Nothing is retained across units of work: no
|
|
453
|
+
* invalidation protocol can make process-local state authoritative after an independent worker
|
|
454
|
+
* commits a map mutation.
|
|
515
455
|
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* (
|
|
456
|
+
* Within one unit of work — the caller's EntityManager, or its open transaction — repeated
|
|
457
|
+
* decisions for the same scope share a single read, so loading N rows or running the before/after
|
|
458
|
+
* hooks of one flush costs one query instead of one per row and hook. That memo dies with the
|
|
459
|
+
* EntityManager/transaction, is dropped for the EntityManager that writes a map
|
|
460
|
+
* (`forgetEncryptionPolicyMemo`) and after its flush, and for every EntityManager when this
|
|
461
|
+
* process invalidates a map. Calls that do not hand in an EntityManager fall back to the
|
|
462
|
+
* long-lived service EntityManager and are never memoized.
|
|
521
463
|
*/
|
|
522
|
-
private
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
464
|
+
private resolvePolicy(key: MapCacheKey, em?: EntityManager): Promise<ResolvedEncryptionPolicy> {
|
|
465
|
+
if (!em) return this.loadPolicy(key)
|
|
466
|
+
const memo = getEncryptionPolicyMemo(em)
|
|
467
|
+
const tag = cacheKey(key)
|
|
468
|
+
const pending = memo.get(tag)
|
|
469
|
+
if (pending) return pending
|
|
470
|
+
const loading = this.loadPolicy(key, em)
|
|
471
|
+
memo.set(tag, loading)
|
|
472
|
+
loading.catch(() => {
|
|
473
|
+
if (memo.get(tag) === loading) memo.delete(tag)
|
|
474
|
+
})
|
|
475
|
+
return loading
|
|
531
476
|
}
|
|
532
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Drops the policy reads memoized by every EntityManager of this process. Map writers call it
|
|
480
|
+
* after their write commits; readers in other processes never depended on it, because policy is
|
|
481
|
+
* not cached across units of work.
|
|
482
|
+
*/
|
|
533
483
|
async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
this.memoryCache.delete(tag)
|
|
537
|
-
this.aggregateMemoryCache.delete(tag)
|
|
538
|
-
this.inflightMaps.delete(tag)
|
|
539
|
-
this.missCache.delete(tag)
|
|
540
|
-
if (this.cache && typeof (this.cache as any).delete === 'function') {
|
|
541
|
-
await (this.cache as any).delete(tag)
|
|
542
|
-
}
|
|
543
|
-
}
|
|
484
|
+
debug('♻️ encmap.invalidate', { entityId, tenantId, organizationId })
|
|
485
|
+
invalidateEncryptionPolicyMemos()
|
|
544
486
|
}
|
|
545
487
|
|
|
546
488
|
// Force a flush of a tenant's cached DEK across the service-level cache and the
|
|
@@ -565,19 +507,16 @@ export class TenantDataEncryptionService {
|
|
|
565
507
|
entityId: string,
|
|
566
508
|
tenantId: string | null | undefined,
|
|
567
509
|
organizationId?: string | null,
|
|
568
|
-
options?: { ignoreRuntimeHealth?: boolean }
|
|
510
|
+
options?: { ignoreRuntimeHealth?: boolean; em?: EntityManager }
|
|
569
511
|
): Promise<string[]> {
|
|
570
512
|
if (options?.ignoreRuntimeHealth) {
|
|
571
513
|
if (!isTenantDataEncryptionEnabled()) return []
|
|
572
514
|
} else if (!this.isEnabled()) {
|
|
573
515
|
return []
|
|
574
516
|
}
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
tenantId ?? null,
|
|
579
|
-
organizationId ?? null,
|
|
580
|
-
map,
|
|
517
|
+
const { fields } = await this.resolvePolicy(
|
|
518
|
+
{ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
|
|
519
|
+
options?.em,
|
|
581
520
|
)
|
|
582
521
|
return fields.map((rule) => rule.field)
|
|
583
522
|
}
|
|
@@ -666,18 +605,15 @@ export class TenantDataEncryptionService {
|
|
|
666
605
|
payload: Record<string, unknown>,
|
|
667
606
|
tenantId: string | null | undefined,
|
|
668
607
|
organizationId?: string | null,
|
|
669
|
-
options?: { createMissingDek?: boolean }
|
|
608
|
+
options?: { createMissingDek?: boolean; em?: EntityManager }
|
|
670
609
|
): Promise<Record<string, unknown>> {
|
|
671
610
|
if (!this.isEnabled()) {
|
|
672
611
|
debug('⚪️ encrypt.skip.disabled', { entityId, tenantId })
|
|
673
612
|
return payload
|
|
674
613
|
}
|
|
675
|
-
const map = await this.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
tenantId ?? null,
|
|
679
|
-
organizationId ?? null,
|
|
680
|
-
map,
|
|
614
|
+
const { map, fields } = await this.resolvePolicy(
|
|
615
|
+
{ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
|
|
616
|
+
options?.em,
|
|
681
617
|
)
|
|
682
618
|
if (!fields.length) {
|
|
683
619
|
debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
|
|
@@ -697,18 +633,16 @@ export class TenantDataEncryptionService {
|
|
|
697
633
|
entityId: string,
|
|
698
634
|
payload: Record<string, unknown>,
|
|
699
635
|
tenantId: string | null | undefined,
|
|
700
|
-
organizationId?: string | null
|
|
636
|
+
organizationId?: string | null,
|
|
637
|
+
options?: { em?: EntityManager }
|
|
701
638
|
): Promise<Record<string, unknown>> {
|
|
702
639
|
if (!isTenantDataEncryptionEnabled()) {
|
|
703
640
|
debug('⚪️ decrypt.skip.disabled', { entityId, tenantId })
|
|
704
641
|
return payload
|
|
705
642
|
}
|
|
706
|
-
const map = await this.
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
tenantId ?? null,
|
|
710
|
-
organizationId ?? null,
|
|
711
|
-
map,
|
|
643
|
+
const { map, fields } = await this.resolvePolicy(
|
|
644
|
+
{ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
|
|
645
|
+
options?.em,
|
|
712
646
|
)
|
|
713
647
|
if (!fields.length) {
|
|
714
648
|
debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
|