@open-mercato/shared 0.10.1-develop.7679.1.cb2c13f49e → 0.10.1-develop.7687.1.e1a6f9de51

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/crud/factory.js +3 -0
  3. package/dist/lib/crud/factory.js.map +2 -2
  4. package/dist/lib/encryption/decryptScope.js +51 -0
  5. package/dist/lib/encryption/decryptScope.js.map +7 -0
  6. package/dist/lib/encryption/find.js +24 -10
  7. package/dist/lib/encryption/find.js.map +2 -2
  8. package/dist/lib/encryption/subscriber.js +73 -15
  9. package/dist/lib/encryption/subscriber.js.map +2 -2
  10. package/dist/lib/query/encrypted-sort.js +15 -6
  11. package/dist/lib/query/encrypted-sort.js.map +2 -2
  12. package/dist/lib/query/engine.js +57 -10
  13. package/dist/lib/query/engine.js.map +2 -2
  14. package/dist/lib/query/types.js.map +1 -1
  15. package/dist/lib/version.js +1 -1
  16. package/dist/lib/version.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/lib/crud/__tests__/crud-factory.test.ts +16 -0
  19. package/src/lib/crud/factory.ts +13 -0
  20. package/src/lib/encryption/__tests__/decrypt-scope.test.ts +134 -0
  21. package/src/lib/encryption/__tests__/subscriber.onload-tenant-binding.test.ts +234 -0
  22. package/src/lib/encryption/__tests__/subscriber.refusal-aggregation.test.ts +105 -0
  23. package/src/lib/encryption/__tests__/subscriber.tenant-binding.test.ts +131 -0
  24. package/src/lib/encryption/decryptScope.ts +119 -0
  25. package/src/lib/encryption/find.ts +17 -12
  26. package/src/lib/encryption/subscriber.ts +101 -19
  27. package/src/lib/query/__tests__/engine-decrypt-refusal-log.test.ts +168 -0
  28. package/src/lib/query/__tests__/engine.test.ts +237 -0
  29. package/src/lib/query/encrypted-sort.ts +34 -8
  30. package/src/lib/query/engine.ts +70 -10
  31. package/src/lib/query/types.ts +15 -0
@@ -0,0 +1,131 @@
1
+ import { ReferenceKind } from '@mikro-orm/core'
2
+ import { TenantEncryptionSubscriber } from '../subscriber'
3
+ import { registerEntityIds } from '../entityIds'
4
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
5
+
6
+ // Coverage for the ORM half of #5430. `findWithDecryption` passes the caller's scope as a fallback,
7
+ // but the entity's own tenant took precedence — so an entity from another tenant that reached a
8
+ // tenant-scoped read was decrypted with that other tenant's DEK. The subscriber now refuses it.
9
+
10
+ const CHILD_META = { className: 'Child', tableName: 'children', properties: {} } as any
11
+
12
+ const PARENT_META = {
13
+ className: 'Parent',
14
+ tableName: 'parents',
15
+ properties: { child: { name: 'child', kind: ReferenceKind.MANY_TO_ONE } },
16
+ } as any
17
+
18
+ const makeEm = () => ({ getMetadata: () => undefined, getComparator: () => undefined })
19
+
20
+ function makeService(calls: Array<{ entityId: string; tenantId: string | null }>): TenantDataEncryptionService {
21
+ return {
22
+ isEnabled: () => true,
23
+ async decryptEntityPayload(
24
+ entityId: string,
25
+ target: Record<string, unknown>,
26
+ tenantId: string | null,
27
+ ) {
28
+ calls.push({ entityId, tenantId })
29
+ const value = target.secret
30
+ if (typeof value === 'string' && value.startsWith('enc:')) return { secret: value.slice('enc:'.length) }
31
+ return {}
32
+ },
33
+ } as unknown as TenantDataEncryptionService
34
+ }
35
+
36
+ describe('TenantEncryptionSubscriber decrypt tenant binding (#5430)', () => {
37
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
38
+
39
+ beforeEach(() => {
40
+ delete process.env.TENANT_DATA_ENCRYPTION
41
+ registerEntityIds({ test: { parent: 'test:parent', child: 'test:child' } })
42
+ })
43
+
44
+ afterEach(() => {
45
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
46
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
47
+ jest.restoreAllMocks()
48
+ })
49
+
50
+ it('leaves an entity untouched when its tenant contradicts the caller scope', async () => {
51
+ const calls: Array<{ entityId: string; tenantId: string | null }> = []
52
+ const entity = { secret: 'enc:alpha', tenantId: 'tenant-b', __meta: CHILD_META }
53
+
54
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(
55
+ entity,
56
+ CHILD_META,
57
+ makeEm() as any,
58
+ { syncOriginal: true, fallbackScope: { tenantId: 'tenant-a', organizationId: null } },
59
+ )
60
+
61
+ expect(calls).toEqual([])
62
+ expect(entity.secret).toBe('enc:alpha')
63
+ })
64
+
65
+ it('still decrypts an entity whose tenant matches the caller scope', async () => {
66
+ const calls: Array<{ entityId: string; tenantId: string | null }> = []
67
+ const entity = { secret: 'enc:alpha', tenantId: 'tenant-a', __meta: CHILD_META }
68
+
69
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(
70
+ entity,
71
+ CHILD_META,
72
+ makeEm() as any,
73
+ { syncOriginal: true, fallbackScope: { tenantId: 'tenant-a', organizationId: null } },
74
+ )
75
+
76
+ expect(calls).toEqual([{ entityId: 'test:child', tenantId: 'tenant-a' }])
77
+ expect(entity.secret).toBe('alpha')
78
+ })
79
+
80
+ it('still decrypts when the caller supplied no scope at all', async () => {
81
+ const calls: Array<{ entityId: string; tenantId: string | null }> = []
82
+ const entity = { secret: 'enc:alpha', tenantId: 'tenant-b', __meta: CHILD_META }
83
+
84
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(
85
+ entity,
86
+ CHILD_META,
87
+ makeEm() as any,
88
+ { syncOriginal: true },
89
+ )
90
+
91
+ expect(calls).toEqual([{ entityId: 'test:child', tenantId: 'tenant-b' }])
92
+ expect(entity.secret).toBe('alpha')
93
+ })
94
+
95
+ it('falls back to the caller tenant for an entity that carries none of its own', async () => {
96
+ const calls: Array<{ entityId: string; tenantId: string | null }> = []
97
+ const entity = { secret: 'enc:alpha', __meta: CHILD_META }
98
+
99
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(
100
+ entity,
101
+ CHILD_META,
102
+ makeEm() as any,
103
+ { syncOriginal: true, fallbackScope: { tenantId: 'tenant-a', organizationId: null } },
104
+ )
105
+
106
+ expect(calls).toEqual([{ entityId: 'test:child', tenantId: 'tenant-a' }])
107
+ expect(entity.secret).toBe('alpha')
108
+ })
109
+
110
+ it('does not leak a refused parent scope into its loaded relations', async () => {
111
+ const calls: Array<{ entityId: string; tenantId: string | null }> = []
112
+ const child = { secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
113
+ const parent = {
114
+ secret: 'enc:alpha',
115
+ tenantId: 'tenant-b',
116
+ child: { isInitialized: () => true, unwrap: () => child },
117
+ __meta: PARENT_META,
118
+ }
119
+
120
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(
121
+ parent,
122
+ PARENT_META,
123
+ makeEm() as any,
124
+ { syncOriginal: true, fallbackScope: { tenantId: 'tenant-a', organizationId: null } },
125
+ )
126
+
127
+ expect(calls).toEqual([])
128
+ expect(parent.secret).toBe('enc:alpha')
129
+ expect(child.secret).toBe('enc:beta')
130
+ })
131
+ })
@@ -0,0 +1,119 @@
1
+ export type DecryptScopeDecision =
2
+ | { decrypt: true; tenantId: string | null; organizationId: string | null }
3
+ | { decrypt: false; reason: 'tenant-mismatch'; rowTenantId: string; callerTenantId: string }
4
+
5
+ export type DecryptScopeInput = {
6
+ rowTenantId?: string | null
7
+ rowOrganizationId?: string | null
8
+ callerTenantId?: string | null
9
+ callerOrganizationId?: string | null
10
+ }
11
+
12
+ /**
13
+ * Decides whether a row may be decrypted, and with which scope.
14
+ *
15
+ * Decryption keys off the row's own `tenant_id` by design: a genuinely cross-tenant read
16
+ * passes no caller tenant, so the row is the only available key id. That precedence becomes
17
+ * dangerous when the caller DID assert a tenant and the row contradicts it — the engine would
18
+ * fetch the foreign tenant's DEK and hand back its plaintext, amplifying a scoping bug instead
19
+ * of containing it. In that state the row is refused: callers return it untouched (ciphertext).
20
+ *
21
+ * Organization is deliberately NOT compared. Organizations inside a tenant share the tenant DEK,
22
+ * so an org mismatch does not change which key is used, and comparing it would break legitimate
23
+ * multi-org reads for no key-scope benefit.
24
+ *
25
+ * That same argument does NOT exempt a `keyScope: 'system'` map, even though its key id is
26
+ * `system:<entityId>` and ignores the tenant entirely, so a mismatch there changes nothing about
27
+ * which key would be used. This helper is intentionally id-only — it never sees the map — so it
28
+ * refuses those rows too. That is conservative rather than correct-by-construction, and it is
29
+ * unreachable today: the only shipped system-scoped map is `onboarding:onboarding_request`, whose
30
+ * readers pass either no scope or the same tenant they filter on. A future system-scoped map read
31
+ * across tenants would need the decision moved to a caller that can see `keyScope`.
32
+ *
33
+ * The refusal branch engages only when BOTH ids are present. Two shapes therefore degrade instead
34
+ * of refusing, and both are safe:
35
+ * - No row tenant (`rowTenantId` null) — the caller's own tenant is used, which is the strongest
36
+ * form of the binding: a foreign row can only ever be attempted with the caller's DEK. Both query
37
+ * engines force `tenant_id` into the decrypt-decision projection so a narrow `fields` list cannot
38
+ * silently land here, but a table without a `tenant_id` column genuinely has no row tenant.
39
+ * - No caller tenant (`callerTenantId` null) — there is no entitlement to bind against, so the row's
40
+ * own tenant wins exactly as it did before this guard existed. The ORM path reaches this whenever
41
+ * a `find*WithDecryption` call passes no scope.
42
+ */
43
+ export function resolveDecryptScope(input: DecryptScopeInput): DecryptScopeDecision {
44
+ const rowTenantId = input.rowTenantId ?? null
45
+ const callerTenantId = input.callerTenantId ?? null
46
+ if (rowTenantId && callerTenantId && rowTenantId !== callerTenantId) {
47
+ return { decrypt: false, reason: 'tenant-mismatch', rowTenantId, callerTenantId }
48
+ }
49
+ return {
50
+ decrypt: true,
51
+ tenantId: rowTenantId ?? callerTenantId,
52
+ organizationId: input.rowOrganizationId ?? input.callerOrganizationId ?? null,
53
+ }
54
+ }
55
+
56
+ export type DecryptEnabledInput = {
57
+ decryptEncryptedFields?: boolean
58
+ }
59
+
60
+ /**
61
+ * Resolves whether a query decrypts its encrypted fields at all.
62
+ *
63
+ * A pure opt-in to declining decryption: unset means today's behaviour, and only an explicit
64
+ * `false` turns decryption off. It is deliberately NOT defaulted off for
65
+ * `omitAutomaticTenantOrgScope` queries — both engines require `opts.tenantId` on every query,
66
+ * so `resolveDecryptScope` always has a caller tenant to bind against and already refuses any
67
+ * foreign-tenant row on those reads. Flipping the default there would only strip plaintext from
68
+ * legitimately global rows (`tenant_id IS NULL`), which the guard correctly allows.
69
+ *
70
+ * Declining also disables the plaintext-sort path, so a sort on an encrypted field degrades to
71
+ * SQL ordering over ciphertext. The engines warn once when that happens.
72
+ */
73
+ export function resolveDecryptEnabled(opts: DecryptEnabledInput): boolean {
74
+ return opts.decryptEncryptedFields !== false
75
+ }
76
+
77
+ const MAX_SAMPLED_TENANT_IDS = 3
78
+
79
+ export const DECRYPT_REFUSAL_LOG_MESSAGE =
80
+ 'Skipped decryption for rows whose tenant does not match the query tenant'
81
+
82
+ export const DECLINED_ENCRYPTED_SORT_LOG_MESSAGE =
83
+ 'Sorting on an encrypted field while decryption is declined; rows are ordered by ciphertext'
84
+
85
+ /**
86
+ * Collects refused rows during one query execution so the caller can emit a single aggregated
87
+ * warning instead of one line per row — a mis-scoped query can refuse a whole page.
88
+ *
89
+ * Refusals are deduplicated by row id because the plaintext-sort path decrypts an overlapping row
90
+ * set twice (a candidate scan, then the page rows); counting both passes would overstate the
91
+ * refusal count by up to a page. Rows recorded without an id cannot be deduplicated and are
92
+ * counted individually.
93
+ */
94
+ export class DecryptRefusalTally {
95
+ private readonly refusedRowIds = new Set<string>()
96
+ private unidentifiedCount = 0
97
+ private readonly rowTenantIds = new Set<string>()
98
+ private callerTenantId: string | null = null
99
+
100
+ record(decision: Extract<DecryptScopeDecision, { decrypt: false }>, rowId?: string | null): void {
101
+ if (rowId === undefined || rowId === null || rowId === '') this.unidentifiedCount += 1
102
+ else this.refusedRowIds.add(String(rowId))
103
+ this.callerTenantId = decision.callerTenantId
104
+ if (this.rowTenantIds.size < MAX_SAMPLED_TENANT_IDS) this.rowTenantIds.add(decision.rowTenantId)
105
+ }
106
+
107
+ get refused(): number {
108
+ return this.refusedRowIds.size + this.unidentifiedCount
109
+ }
110
+
111
+ toLogContext(entity: string): Record<string, unknown> {
112
+ return {
113
+ entity,
114
+ refusedRows: this.refused,
115
+ callerTenantId: this.callerTenantId,
116
+ rowTenantIds: Array.from(this.rowTenantIds),
117
+ }
118
+ }
119
+ }
@@ -6,7 +6,8 @@ import type {
6
6
  FindOptions,
7
7
  } from '@mikro-orm/postgresql'
8
8
  import { warnOnEncryptedLikeFilter } from './likeFilterWarning'
9
- import { decryptEntitiesWithFallbackScope } from './subscriber'
9
+ import { decryptEntitiesWithFallbackScope, runWithCallerDecryptScope } from './subscriber'
10
+ import { DecryptRefusalTally } from './decryptScope'
10
11
  import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
11
12
 
12
13
  export type DecryptionScope = {
@@ -32,15 +33,17 @@ export async function findWithDecryption<Entity extends object, Hint extends str
32
33
  tenantId: scope?.tenantId,
33
34
  encryptionService: scope?.encryptionService,
34
35
  })
35
- const records = (await em.find<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
36
- | Entity[]
37
- | undefined
36
+ const refusalTallies = new Map<string, DecryptRefusalTally>()
37
+ const records = (await runWithCallerDecryptScope(scope ?? {}, refusalTallies, () =>
38
+ em.find<Entity, Hint, any, any>(entityName as any, where as any, options as any),
39
+ )) as any as Entity[] | undefined
38
40
  if (!Array.isArray(records) || records.length === 0) return records ?? []
39
41
  await decryptEntitiesWithFallbackScope(records, {
40
42
  em,
41
43
  tenantId: scope?.tenantId ?? null,
42
44
  organizationId: scope?.organizationId ?? null,
43
45
  encryptionService: scope?.encryptionService ?? null,
46
+ refusalTallies,
44
47
  })
45
48
  return records
46
49
  }
@@ -59,15 +62,17 @@ export async function findOneWithDecryption<Entity extends object, Hint extends
59
62
  tenantId: scope?.tenantId,
60
63
  encryptionService: scope?.encryptionService,
61
64
  })
62
- const record = (await em.findOne<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
63
- | Entity
64
- | null
65
+ const refusalTallies = new Map<string, DecryptRefusalTally>()
66
+ const record = (await runWithCallerDecryptScope(scope ?? {}, refusalTallies, () =>
67
+ em.findOne<Entity, Hint, any, any>(entityName as any, where as any, options as any),
68
+ )) as any as Entity | null
65
69
  if (!record) return record
66
70
  await decryptEntitiesWithFallbackScope(record, {
67
71
  em,
68
72
  tenantId: scope?.tenantId ?? null,
69
73
  organizationId: scope?.organizationId ?? null,
70
74
  encryptionService: scope?.encryptionService ?? null,
75
+ refusalTallies,
71
76
  })
72
77
  return record
73
78
  }
@@ -86,11 +91,10 @@ export async function findAndCountWithDecryption<Entity extends object, Hint ext
86
91
  tenantId: scope?.tenantId,
87
92
  encryptionService: scope?.encryptionService,
88
93
  })
89
- const [recordsRaw, count] = await em.findAndCount<Entity, Hint, any, any>(
90
- entityName as any,
91
- where as any,
92
- options as any,
93
- ) as any as [Entity[] | undefined, number]
94
+ const refusalTallies = new Map<string, DecryptRefusalTally>()
95
+ const [recordsRaw, count] = (await runWithCallerDecryptScope(scope ?? {}, refusalTallies, () =>
96
+ em.findAndCount<Entity, Hint, any, any>(entityName as any, where as any, options as any),
97
+ )) as any as [Entity[] | undefined, number]
94
98
  const records = Array.isArray(recordsRaw) ? recordsRaw : []
95
99
  if (!records.length) return [records, count]
96
100
  await decryptEntitiesWithFallbackScope(records, {
@@ -98,6 +102,7 @@ export async function findAndCountWithDecryption<Entity extends object, Hint ext
98
102
  tenantId: scope?.tenantId ?? null,
99
103
  organizationId: scope?.organizationId ?? null,
100
104
  encryptionService: scope?.encryptionService ?? null,
105
+ refusalTallies,
101
106
  })
102
107
  return [records, count]
103
108
  }
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
1
2
  import type { EntityMetadata, EventArgs, EventSubscriber, FlushEventArgs } from '@mikro-orm/core'
2
3
  import type { EntityManager } from '@mikro-orm/postgresql'
3
4
  import { DriverException, ReferenceKind } from '@mikro-orm/core'
@@ -8,6 +9,7 @@ import { isTenantDataEncryptionEnabled } from './toggles'
8
9
  import { isEncryptionDebugEnabled } from './toggles'
9
10
  import { resolveTenantEncryptionService } from './customFieldValues'
10
11
  import { createLogger } from '../logger'
12
+ import { DECRYPT_REFUSAL_LOG_MESSAGE, DecryptRefusalTally, resolveDecryptScope } from './decryptScope'
11
13
  import { listEntityMetadataFromRegistry } from '../db/entityMetadata'
12
14
 
13
15
  const logger = createLogger('shared').child({ component: 'encryption' })
@@ -21,7 +23,38 @@ type Scoped = {
21
23
  organization?: { id?: string | null } | null
22
24
  }
23
25
 
24
- type Scope = { tenantId: string | null; organizationId: string | null }
26
+ type Scope = { tenantId: string | null; organizationId: string | null; bindsTenant?: boolean }
27
+
28
+ type CallerDecryptContext = { scope: Scope; refusalTallies: Map<string, DecryptRefusalTally> }
29
+
30
+ const callerDecryptContext = new AsyncLocalStorage<CallerDecryptContext>()
31
+
32
+ function toCallerScope(tenantId?: string | null, organizationId?: string | null): Scope | undefined {
33
+ if (!tenantId && !organizationId) return undefined
34
+ return { tenantId: tenantId ?? null, organizationId: organizationId ?? null }
35
+ }
36
+
37
+ /**
38
+ * Runs an ORM read so that the `onLoad` / `afterFind` hooks it triggers decrypt under the caller's
39
+ * scope instead of the loaded row's own tenant. Without it, hydration would decrypt a foreign-tenant
40
+ * entity with that tenant's DEK before `find*WithDecryption` gets to apply the tenant binding.
41
+ * Refusals raised during hydration are collected in `refusalTallies` so the caller reports them once.
42
+ */
43
+ export async function runWithCallerDecryptScope<T>(
44
+ scope: { tenantId?: string | null; organizationId?: string | null },
45
+ refusalTallies: Map<string, DecryptRefusalTally>,
46
+ read: () => Promise<T>,
47
+ ): Promise<T> {
48
+ const callerScope = toCallerScope(scope.tenantId, scope.organizationId)
49
+ if (!callerScope) return callerDecryptContext.exit(read)
50
+ return callerDecryptContext.run({ scope: callerScope, refusalTallies }, read)
51
+ }
52
+
53
+ function readCallerDecryptContext(): { fallbackScope?: Scope; refusalTallies?: Map<string, DecryptRefusalTally> } {
54
+ const context = callerDecryptContext.getStore()
55
+ if (!context) return {}
56
+ return { fallbackScope: context.scope, refusalTallies: context.refusalTallies }
57
+ }
25
58
 
26
59
  function resolveScope(entity: Scoped): Scope {
27
60
  const tenantId = entity.tenantId ?? entity.tenant_id ?? entity.tenant?.id ?? null
@@ -344,11 +377,36 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
344
377
  target: Record<string, unknown>,
345
378
  meta: EntityMetadata<any> | undefined,
346
379
  em?: { getMetadata?: () => any; getComparator?: () => any },
347
- opts: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},
380
+ opts: {
381
+ syncOriginal?: boolean
382
+ seen?: WeakSet<object>
383
+ fallbackScope?: Scope
384
+ refusalTallies?: Map<string, DecryptRefusalTally>
385
+ } = {},
348
386
  ) {
349
387
  await this.decrypt(target, meta, em, opts)
350
388
  }
351
389
 
390
+ /**
391
+ * Scope handed to a loaded relation. The caller's tenant binding is inherited only through a parent
392
+ * that carries a tenant of its own (and so already passed the binding): a tenant-less parent such as
393
+ * a join row may legitimately link entities of several tenants, so its relations keep the caller's
394
+ * scope as a key fallback but are not refused against it. Entities hydrated by the same read are
395
+ * still checked against the caller directly in `onLoad`.
396
+ */
397
+ private resolveRelationFallback(
398
+ fallbackScope: Scope | undefined,
399
+ tenantId: string | null,
400
+ organizationId: string | null,
401
+ ): Scope {
402
+ if (tenantId) {
403
+ if (fallbackScope && fallbackScope.bindsTenant !== false) return fallbackScope
404
+ return { tenantId, organizationId }
405
+ }
406
+ if (fallbackScope) return { ...fallbackScope, bindsTenant: false }
407
+ return { tenantId: null, organizationId }
408
+ }
409
+
352
410
  private async decrypt(
353
411
  target: Record<string, unknown>,
354
412
  meta: EntityMetadata<any> | undefined,
@@ -357,7 +415,13 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
357
415
  syncOriginal = false,
358
416
  seen,
359
417
  fallbackScope,
360
- }: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},
418
+ refusalTallies,
419
+ }: {
420
+ syncOriginal?: boolean
421
+ seen?: WeakSet<object>
422
+ fallbackScope?: Scope
423
+ refusalTallies?: Map<string, DecryptRefusalTally>
424
+ } = {},
361
425
  ) {
362
426
  const visited = seen ?? new WeakSet<object>()
363
427
  if (visited.has(target as object)) return
@@ -370,8 +434,27 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
370
434
  const entityId = this.resolveEntityId(resolvedMeta)
371
435
  if (!entityId) return
372
436
  const { tenantId, organizationId } = resolveScope(target)
373
- const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null
374
- const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null
437
+ const decision = resolveDecryptScope({
438
+ rowTenantId: tenantId,
439
+ rowOrganizationId: organizationId,
440
+ callerTenantId: fallbackScope?.bindsTenant === false ? null : fallbackScope?.tenantId ?? null,
441
+ callerOrganizationId: fallbackScope?.organizationId ?? null,
442
+ })
443
+ if (!decision.decrypt) {
444
+ const rowId = (target as { id?: unknown }).id
445
+ if (refusalTallies) {
446
+ const tally = refusalTallies.get(entityId) ?? new DecryptRefusalTally()
447
+ refusalTallies.set(entityId, tally)
448
+ tally.record(decision, rowId == null ? null : String(rowId))
449
+ } else {
450
+ const tally = new DecryptRefusalTally()
451
+ tally.record(decision, rowId == null ? null : String(rowId))
452
+ logger.warn(DECRYPT_REFUSAL_LOG_MESSAGE, tally.toLogContext(entityId))
453
+ }
454
+ return
455
+ }
456
+ const scopedTenantId = decision.tenantId ?? fallbackScope?.tenantId ?? null
457
+ const scopedOrgId = decision.organizationId
375
458
  // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a
376
459
  // managed entity that a command already mutated would clear its dirty changeset and silently
377
460
  // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
@@ -385,11 +468,7 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
385
468
  if (syncOriginal && !hadPendingChanges) {
386
469
  this.syncOriginalEntityData(target, resolvedMeta, em as any)
387
470
  }
388
- const nextFallback =
389
- fallbackScope ??
390
- (tenantId || organizationId
391
- ? { tenantId: tenantId ?? null, organizationId: organizationId ?? null }
392
- : { tenantId: scopedTenantId, organizationId: scopedOrgId })
471
+ const nextFallback = this.resolveRelationFallback(fallbackScope, tenantId, organizationId)
393
472
  // Best-effort deep decrypt for loaded relations so populated graphs get cleaned too.
394
473
  try {
395
474
  const extractEntities = (value: any): any[] => {
@@ -437,6 +516,7 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
437
516
  syncOriginal: true,
438
517
  seen: visited,
439
518
  fallbackScope: nextFallback,
519
+ refusalTallies,
440
520
  })
441
521
  }
442
522
  continue
@@ -451,6 +531,7 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
451
531
  syncOriginal: true,
452
532
  seen: visited,
453
533
  fallbackScope: nextFallback,
534
+ refusalTallies,
454
535
  })
455
536
  }
456
537
  }
@@ -488,7 +569,7 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
488
569
  }
489
570
 
490
571
  async onLoad(args: EventArgs<any>) {
491
- await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })
572
+ await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true, ...readCallerDecryptContext() })
492
573
  }
493
574
 
494
575
  afterFlush(args: FlushEventArgs) {
@@ -498,7 +579,7 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
498
579
  async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {
499
580
  const entities = Array.isArray(args.entities) ? args.entities : []
500
581
  for (const entity of entities) {
501
- await this.decrypt(entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })
582
+ await this.decrypt(entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true, ...readCallerDecryptContext() })
502
583
  }
503
584
  }
504
585
  }
@@ -521,11 +602,13 @@ export async function decryptEntitiesWithFallbackScope(
521
602
  tenantId,
522
603
  organizationId,
523
604
  encryptionService,
605
+ refusalTallies = new Map<string, DecryptRefusalTally>(),
524
606
  }: {
525
607
  em: { getMetadata?: () => any; getComparator?: () => any }
526
608
  tenantId?: string | null
527
609
  organizationId?: string | null
528
610
  encryptionService?: TenantDataEncryptionService | null
611
+ refusalTallies?: Map<string, DecryptRefusalTally>
529
612
  },
530
613
  ): Promise<void> {
531
614
  if (!isTenantDataEncryptionEnabled()) return
@@ -534,19 +617,18 @@ export async function decryptEntitiesWithFallbackScope(
534
617
  const service = encryptionService ?? resolveTenantEncryptionService(em as any)
535
618
  if (!service || !service.isEnabled()) return
536
619
  const subscriber = getSubscriberForService(service)
537
- const fallback: Scope | undefined =
538
- tenantId || organizationId
539
- ? {
540
- tenantId: tenantId ?? null,
541
- organizationId: organizationId ?? null,
542
- }
543
- : undefined
620
+ const fallback = toCallerScope(tenantId, organizationId)
544
621
  for (const entity of list) {
545
622
  if (!entity || typeof entity !== 'object') continue
546
623
  const meta = (entity as any).__meta ?? (entity as any).__helper?.__meta
547
624
  await subscriber.decryptEntityGraph(entity as Record<string, unknown>, meta, em as any, {
548
625
  syncOriginal: true,
549
626
  fallbackScope: fallback,
627
+ refusalTallies,
550
628
  })
551
629
  }
630
+ for (const [entityId, tally] of refusalTallies) {
631
+ if (tally.refused === 0) continue
632
+ logger.warn(DECRYPT_REFUSAL_LOG_MESSAGE, tally.toLogContext(entityId))
633
+ }
552
634
  }