@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/crud/factory.js +3 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/encryption/decryptScope.js +51 -0
- package/dist/lib/encryption/decryptScope.js.map +7 -0
- package/dist/lib/encryption/find.js +24 -10
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +73 -15
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/query/encrypted-sort.js +15 -6
- package/dist/lib/query/encrypted-sort.js.map +2 -2
- package/dist/lib/query/engine.js +57 -10
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +16 -0
- package/src/lib/crud/factory.ts +13 -0
- package/src/lib/encryption/__tests__/decrypt-scope.test.ts +134 -0
- package/src/lib/encryption/__tests__/subscriber.onload-tenant-binding.test.ts +234 -0
- package/src/lib/encryption/__tests__/subscriber.refusal-aggregation.test.ts +105 -0
- package/src/lib/encryption/__tests__/subscriber.tenant-binding.test.ts +131 -0
- package/src/lib/encryption/decryptScope.ts +119 -0
- package/src/lib/encryption/find.ts +17 -12
- package/src/lib/encryption/subscriber.ts +101 -19
- package/src/lib/query/__tests__/engine-decrypt-refusal-log.test.ts +168 -0
- package/src/lib/query/__tests__/engine.test.ts +237 -0
- package/src/lib/query/encrypted-sort.ts +34 -8
- package/src/lib/query/engine.ts +70 -10
- 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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
where as any,
|
|
92
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
|
374
|
-
|
|
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
|
|
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
|
}
|