@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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 +10 -0
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/command-bus.js +56 -47
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +6 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +106 -0
- package/dist/lib/commands/redo.js.map +7 -0
- package/dist/lib/commands/runCrudCommandWrite.js +38 -0
- package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/errors.js +22 -0
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/data/engine.js +2 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +57 -23
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +41 -8
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/context.js +5 -0
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/package.json +8 -9
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
- package/src/lib/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/__tests__/redo.test.ts +265 -0
- package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/command-bus.ts +62 -44
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/redo.ts +235 -0
- package/src/lib/commands/runCrudCommandWrite.ts +82 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/commands/types.ts +54 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/errors.ts +29 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/data/engine.ts +11 -8
- package/src/lib/di/container.ts +17 -1
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +44 -6
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +76 -24
- package/src/lib/encryption/subscriber.ts +54 -9
- package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
- package/src/lib/i18n/context.tsx +11 -0
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/integrations/types.ts +14 -0
- package/src/modules/notifications/handler.ts +7 -0
- package/src/modules/search.ts +9 -0
- package/src/modules/vector.ts +7 -0
|
@@ -22,6 +22,7 @@ export interface KmsService {
|
|
|
22
22
|
getTenantDek(tenantId: string): Promise<TenantDek | null>
|
|
23
23
|
createTenantDek(tenantId: string): Promise<TenantDek | null>
|
|
24
24
|
isHealthy(): boolean
|
|
25
|
+
invalidateDek?(tenantId: string): void
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
class FallbackKmsService implements KmsService {
|
|
@@ -76,6 +77,11 @@ class FallbackKmsService implements KmsService {
|
|
|
76
77
|
}
|
|
77
78
|
return null
|
|
78
79
|
}
|
|
80
|
+
|
|
81
|
+
invalidateDek(tenantId: string): void {
|
|
82
|
+
this.primary.invalidateDek?.(tenantId)
|
|
83
|
+
this.fallback?.invalidateDek?.(tenantId)
|
|
84
|
+
}
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
type VaultClientOpts = {
|
|
@@ -90,6 +96,10 @@ type VaultReadResponse = {
|
|
|
90
96
|
data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
// 'conflict' = a check-and-set write lost to a concurrent writer (normal race
|
|
100
|
+
// outcome, Vault still healthy); 'error' = the write genuinely failed.
|
|
101
|
+
type VaultWriteOutcome = 'ok' | 'conflict' | 'error'
|
|
102
|
+
|
|
93
103
|
function normalizeEnv(value: string | undefined): string {
|
|
94
104
|
if (!value) return ''
|
|
95
105
|
return value.trim().replace(/(?:^['"]|['"]$)/g, '')
|
|
@@ -230,11 +240,13 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
230
240
|
}
|
|
231
241
|
}
|
|
232
242
|
|
|
233
|
-
private async writeVault(path: string, key: string): Promise<
|
|
243
|
+
private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {
|
|
234
244
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
235
245
|
this.healthy = false
|
|
236
|
-
return
|
|
246
|
+
return 'error'
|
|
237
247
|
}
|
|
248
|
+
const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }
|
|
249
|
+
if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }
|
|
238
250
|
try {
|
|
239
251
|
const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {
|
|
240
252
|
method: 'POST',
|
|
@@ -242,14 +254,23 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
242
254
|
'X-Vault-Token': this.vaultToken,
|
|
243
255
|
'Content-Type': 'application/json',
|
|
244
256
|
},
|
|
245
|
-
body: JSON.stringify(
|
|
257
|
+
body: JSON.stringify(body),
|
|
246
258
|
timeoutMs: this.requestTimeoutMs,
|
|
247
259
|
})
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
260
|
+
if (res.ok) {
|
|
261
|
+
this.healthy = true
|
|
262
|
+
return 'ok'
|
|
251
263
|
}
|
|
252
|
-
|
|
264
|
+
// KV v2 returns 400 when a check-and-set write loses to a concurrent
|
|
265
|
+
// writer (path already at a newer version). That is a normal race outcome,
|
|
266
|
+
// not an unhealthy Vault — don't flip `healthy`.
|
|
267
|
+
if (typeof opts?.cas === 'number' && res.status === 400) {
|
|
268
|
+
console.warn('⚠️ [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })
|
|
269
|
+
return 'conflict'
|
|
270
|
+
}
|
|
271
|
+
this.healthy = false
|
|
272
|
+
console.warn('⚠️ [encryption][kms] Vault write failed', { path, status: res.status })
|
|
273
|
+
return 'error'
|
|
253
274
|
} catch (err) {
|
|
254
275
|
this.healthy = false
|
|
255
276
|
console.warn('⚠️ [encryption][kms] Vault write error', {
|
|
@@ -257,7 +278,7 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
257
278
|
error: (err as Error)?.message || String(err),
|
|
258
279
|
timeoutMs: this.requestTimeoutMs,
|
|
259
280
|
})
|
|
260
|
-
return
|
|
281
|
+
return 'error'
|
|
261
282
|
}
|
|
262
283
|
}
|
|
263
284
|
|
|
@@ -287,21 +308,60 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
287
308
|
}
|
|
288
309
|
|
|
289
310
|
async createTenantDek(tenantId: string): Promise<TenantDek | null> {
|
|
290
|
-
const key = generateDek()
|
|
291
311
|
const path = this.buildKeyPath(tenantId)
|
|
292
|
-
|
|
293
|
-
|
|
312
|
+
// Read-before-write: if a DEK already exists for this tenant (another request
|
|
313
|
+
// or process created it first), adopt it instead of overwriting the active
|
|
314
|
+
// key — overwriting orphans every row already encrypted under it (#2746).
|
|
315
|
+
const existing = await this.readVault(path)
|
|
316
|
+
const existingKey = existing?.data?.data?.key
|
|
317
|
+
if (existingKey) {
|
|
318
|
+
return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })
|
|
319
|
+
}
|
|
320
|
+
// A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new
|
|
321
|
+
// key over a possibly-existing one we just couldn't read — let the caller fall back.
|
|
322
|
+
if (!this.healthy) return null
|
|
323
|
+
const key = generateDek()
|
|
324
|
+
const outcome = await this.writeVault(path, key, { cas: 0 })
|
|
325
|
+
if (outcome === 'ok') {
|
|
294
326
|
console.info('🔑 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })
|
|
295
|
-
|
|
296
|
-
|
|
327
|
+
return this.remember({ tenantId, key, fetchedAt: this.now() })
|
|
328
|
+
}
|
|
329
|
+
if (outcome === 'conflict') {
|
|
330
|
+
// A concurrent create won the CAS race — adopt the winner's key so both
|
|
331
|
+
// callers encrypt under the same DEK.
|
|
332
|
+
const winner = await this.readVault(path)
|
|
333
|
+
const winnerKey = winner?.data?.data?.key
|
|
334
|
+
if (winnerKey) {
|
|
335
|
+
console.info('🔑 [encryption][kms] Adopted concurrently-created tenant DEK', { tenantId, path })
|
|
336
|
+
return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })
|
|
337
|
+
}
|
|
297
338
|
}
|
|
298
|
-
|
|
299
|
-
return
|
|
339
|
+
console.warn('⚠️ [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
invalidateDek(tenantId: string): void {
|
|
344
|
+
this.cache.delete(tenantId)
|
|
300
345
|
}
|
|
301
346
|
}
|
|
302
347
|
|
|
303
348
|
let loggedDerivedKeyFallbackBanner = false
|
|
304
349
|
|
|
350
|
+
function fingerprintSecret(secret: string): string {
|
|
351
|
+
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {
|
|
355
|
+
const sourceLine =
|
|
356
|
+
opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'
|
|
357
|
+
return [
|
|
358
|
+
'🚨 Using derived tenant encryption keys (Vault unavailable / no DEK)',
|
|
359
|
+
sourceLine,
|
|
360
|
+
`Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,
|
|
361
|
+
'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',
|
|
362
|
+
]
|
|
363
|
+
}
|
|
364
|
+
|
|
305
365
|
function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
|
|
306
366
|
if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return
|
|
307
367
|
loggedDerivedKeyFallbackBanner = true
|
|
@@ -310,15 +370,7 @@ function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
|
|
|
310
370
|
const reset = '\x1b[0m'
|
|
311
371
|
const width = 110
|
|
312
372
|
const border = `${redBg}${white}${'━'.repeat(width)}${reset}`
|
|
313
|
-
const
|
|
314
|
-
const sourceLine =
|
|
315
|
-
opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'
|
|
316
|
-
const body = [
|
|
317
|
-
'🚨 Using derived tenant encryption keys (Vault unavailable / no DEK)',
|
|
318
|
-
sourceLine,
|
|
319
|
-
isProduction ? 'Secret: [redacted in production]' : `Secret: ${opts.secret}`,
|
|
320
|
-
'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',
|
|
321
|
-
]
|
|
373
|
+
const body = buildDerivedKeyFallbackBannerLines(opts)
|
|
322
374
|
console.warn(border)
|
|
323
375
|
for (const line of body) {
|
|
324
376
|
const padded = line.padEnd(width - 2, ' ')
|
|
@@ -139,6 +139,43 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
139
139
|
helper.__touched = false
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Reports whether a managed entity currently carries un-flushed changes relative to its load
|
|
144
|
+
* baseline, using MikroORM's own comparator so the verdict matches the change-set computer that
|
|
145
|
+
* runs at flush time. Used to avoid re-baselining (and thereby discarding) pending writes when a
|
|
146
|
+
* decrypt pass traverses back into an entity a command already mutated.
|
|
147
|
+
*/
|
|
148
|
+
private hasPendingChanges(
|
|
149
|
+
target: Record<string, unknown>,
|
|
150
|
+
meta: EntityMetadata<any> | undefined,
|
|
151
|
+
em?: { getComparator?: () => any },
|
|
152
|
+
): boolean {
|
|
153
|
+
const helper = (target as any)?.__helper
|
|
154
|
+
if (!helper || typeof helper !== 'object') return false
|
|
155
|
+
const original = helper.__originalEntityData
|
|
156
|
+
if (!original) return false
|
|
157
|
+
const entityName = meta?.className || meta?.name
|
|
158
|
+
try {
|
|
159
|
+
const comparator = em?.getComparator?.()
|
|
160
|
+
if (entityName && comparator?.prepareEntity) {
|
|
161
|
+
const current = comparator.prepareEntity(target)
|
|
162
|
+
if (typeof comparator.matching === 'function') {
|
|
163
|
+
return !comparator.matching(entityName, original, current)
|
|
164
|
+
}
|
|
165
|
+
if (typeof comparator.diffEntities === 'function') {
|
|
166
|
+
const diff = comparator.diffEntities(entityName, original, current)
|
|
167
|
+
return !!diff && Object.keys(diff).length > 0
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
debug('⚪️ subscriber.pending_changes.compare_failed', {
|
|
172
|
+
entity: entityName,
|
|
173
|
+
message: (err as Error)?.message ?? String(err),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
return helper.__touched === true
|
|
177
|
+
}
|
|
178
|
+
|
|
142
179
|
private async encrypt(
|
|
143
180
|
target: Record<string, unknown>,
|
|
144
181
|
meta: EntityMetadata<any> | undefined,
|
|
@@ -264,9 +301,14 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
264
301
|
debug('⚪️ subscriber.skip', { reason: 'no-tenant', entityId })
|
|
265
302
|
return
|
|
266
303
|
}
|
|
304
|
+
// Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a
|
|
305
|
+
// managed entity that a command already mutated would clear its dirty changeset and silently
|
|
306
|
+
// drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
|
|
307
|
+
// encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).
|
|
308
|
+
const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false
|
|
267
309
|
const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)
|
|
268
310
|
Object.assign(target, decrypted)
|
|
269
|
-
if (syncOriginal) {
|
|
311
|
+
if (syncOriginal && !hadPendingChanges) {
|
|
270
312
|
this.syncOriginalEntityData(target, resolvedMeta, em as any)
|
|
271
313
|
}
|
|
272
314
|
const nextFallback =
|
|
@@ -278,6 +320,17 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
278
320
|
try {
|
|
279
321
|
const extractEntities = (value: any): any[] => {
|
|
280
322
|
if (!value) return []
|
|
323
|
+
// MikroORM Collection wrapper — MUST be checked before the Reference branch: both wrappers
|
|
324
|
+
// expose isInitialized(), but only a Collection exposes getItems(). Matching the Reference
|
|
325
|
+
// branch first returned the Collection wrapper itself instead of its items, so collection
|
|
326
|
+
// relations were never deep-decrypted and leaked ciphertext (issue #2744).
|
|
327
|
+
if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {
|
|
328
|
+
try {
|
|
329
|
+
return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []
|
|
330
|
+
} catch {
|
|
331
|
+
return []
|
|
332
|
+
}
|
|
333
|
+
}
|
|
281
334
|
// MikroORM Reference wrapper
|
|
282
335
|
if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {
|
|
283
336
|
try {
|
|
@@ -290,14 +343,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
290
343
|
}
|
|
291
344
|
return []
|
|
292
345
|
}
|
|
293
|
-
// Collection wrapper
|
|
294
|
-
if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {
|
|
295
|
-
try {
|
|
296
|
-
return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []
|
|
297
|
-
} catch {
|
|
298
|
-
return []
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
346
|
if (Array.isArray(value)) return value
|
|
302
347
|
if (typeof value === 'object') return [value]
|
|
303
348
|
return []
|
|
@@ -22,6 +22,10 @@ type MapCacheKey = {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const MAP_MISS_TTL_MS = 5 * 60 * 1000
|
|
25
|
+
// Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked
|
|
26
|
+
// up by long-lived processes without a restart (#2746). The service-level cache
|
|
27
|
+
// previously had no TTL and shadowed the KMS's own 15-minute expiry.
|
|
28
|
+
const DEK_CACHE_TTL_MS = 15 * 60 * 1000
|
|
25
29
|
|
|
26
30
|
function cacheKey(key: MapCacheKey): string {
|
|
27
31
|
return [
|
|
@@ -86,21 +90,33 @@ export function parseDecryptedFieldValue(decrypted: string): unknown {
|
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
|
|
93
|
+
/**
|
|
94
|
+
* A value is only treated as "already encrypted" when it actually decrypts
|
|
95
|
+
* under the tenant DEK — i.e. the AES-GCM authentication tag verifies. A purely
|
|
96
|
+
* structural `<iv>:<ct>:<tag>:v1` shape check is forgeable: attacker-controlled
|
|
97
|
+
* field values (e.g. their own profile email/phone) could impersonate ciphertext
|
|
98
|
+
* to skip encryption-at-rest and the lookup hash entirely (issue #2720). Binding
|
|
99
|
+
* the check to a successful authenticated decrypt makes forgery infeasible, so a
|
|
100
|
+
* fake payload simply gets encrypted like any other plaintext.
|
|
101
|
+
*/
|
|
102
|
+
function isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {
|
|
90
103
|
if (typeof value !== 'string') return false
|
|
91
104
|
const parts = value.split(':')
|
|
92
|
-
|
|
105
|
+
if (parts.length !== 4 || parts[3] !== 'v1') return false
|
|
106
|
+
return decryptWithAesGcm(value, dek.key) !== null
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
export class TenantDataEncryptionService {
|
|
96
110
|
private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
|
|
97
111
|
private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
|
|
98
112
|
private static globalDekCache = new Map<string, TenantDek>()
|
|
113
|
+
private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()
|
|
99
114
|
private static globalMissCache = new Map<string, number>()
|
|
100
115
|
private readonly kms: KmsService
|
|
101
116
|
private readonly cache?: CacheStrategy
|
|
102
117
|
private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
|
|
103
118
|
private readonly dekCache = TenantDataEncryptionService.globalDekCache
|
|
119
|
+
private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
|
|
104
120
|
private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
|
|
105
121
|
private readonly missCache = TenantDataEncryptionService.globalMissCache
|
|
106
122
|
|
|
@@ -116,10 +132,15 @@ export class TenantDataEncryptionService {
|
|
|
116
132
|
return isTenantDataEncryptionEnabled() && this.kms.isHealthy()
|
|
117
133
|
}
|
|
118
134
|
|
|
135
|
+
private isDekExpired(dek: TenantDek): boolean {
|
|
136
|
+
return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {
|
|
120
140
|
if (!tenantId) return null
|
|
121
141
|
const cached = this.dekCache.get(tenantId)
|
|
122
|
-
if (cached) return cached
|
|
142
|
+
if (cached && !this.isDekExpired(cached)) return cached
|
|
143
|
+
if (cached) this.dekCache.delete(tenantId)
|
|
123
144
|
const dek = await this.kms.getTenantDek(tenantId)
|
|
124
145
|
if (!dek) {
|
|
125
146
|
debug('🔎 dek.miss', { tenantId })
|
|
@@ -134,9 +155,22 @@ export class TenantDataEncryptionService {
|
|
|
134
155
|
const existing = await this.getDek(tenantId)
|
|
135
156
|
if (existing || !tenantId) return existing ?? null
|
|
136
157
|
if (typeof this.kms.createTenantDek !== 'function') return existing ?? null
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
158
|
+
// Dedupe concurrent first-time creation within this process so two callers
|
|
159
|
+
// can't each generate a distinct DEK and overwrite one another (#2746).
|
|
160
|
+
// Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).
|
|
161
|
+
const pending = this.inflightDeks.get(tenantId)
|
|
162
|
+
if (pending) return pending
|
|
163
|
+
const creation = (async () => {
|
|
164
|
+
const created = await this.kms.createTenantDek(tenantId)
|
|
165
|
+
if (created) this.dekCache.set(tenantId, created)
|
|
166
|
+
return created ?? null
|
|
167
|
+
})()
|
|
168
|
+
this.inflightDeks.set(tenantId, creation)
|
|
169
|
+
try {
|
|
170
|
+
return await creation
|
|
171
|
+
} finally {
|
|
172
|
+
this.inflightDeks.delete(tenantId)
|
|
173
|
+
}
|
|
140
174
|
}
|
|
141
175
|
|
|
142
176
|
async createDek(tenantId: string): Promise<TenantDek | null> {
|
|
@@ -236,6 +270,15 @@ export class TenantDataEncryptionService {
|
|
|
236
270
|
}
|
|
237
271
|
}
|
|
238
272
|
|
|
273
|
+
// Force a flush of a tenant's cached DEK across the service-level cache and the
|
|
274
|
+
// underlying KMS cache so an operator can pick up a rotated/revoked key without
|
|
275
|
+
// a process restart (#2746).
|
|
276
|
+
invalidateDek(tenantId: string): void {
|
|
277
|
+
this.dekCache.delete(tenantId)
|
|
278
|
+
this.inflightDeks.delete(tenantId)
|
|
279
|
+
this.kms.invalidateDek?.(tenantId)
|
|
280
|
+
}
|
|
281
|
+
|
|
239
282
|
async getEncryptedFieldNames(
|
|
240
283
|
entityId: string,
|
|
241
284
|
tenantId: string | null | undefined,
|
|
@@ -260,8 +303,10 @@ export class TenantDataEncryptionService {
|
|
|
260
303
|
if (!key) continue
|
|
261
304
|
const value = clone[key]
|
|
262
305
|
if (value === null || value === undefined) continue
|
|
263
|
-
|
|
264
|
-
|
|
306
|
+
// Avoid double-encrypting payloads that genuinely decrypt under this DEK.
|
|
307
|
+
// A forged ciphertext-shaped string fails this check and is encrypted as
|
|
308
|
+
// plaintext, closing the encryption-at-rest bypass (issue #2720).
|
|
309
|
+
if (isEncryptedWithDek(value, dek)) continue
|
|
265
310
|
const serialized = typeof value === 'string' ? value : JSON.stringify(value)
|
|
266
311
|
const payload = encryptWithAesGcm(serialized, dek.key)
|
|
267
312
|
clone[key] = payload.value
|
package/src/lib/i18n/context.tsx
CHANGED
|
@@ -73,6 +73,17 @@ export function useT() {
|
|
|
73
73
|
return ctx.t
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Like `useT`, but returns `undefined` instead of throwing when no
|
|
78
|
+
* `I18nProvider` is in scope. Use where a translator is desirable but not
|
|
79
|
+
* guaranteed (e.g. plumbing `t` into side-effect handlers that may run before
|
|
80
|
+
* the provider mounts) — callers MUST provide a fallback.
|
|
81
|
+
*/
|
|
82
|
+
export function useOptionalT(): TranslateFn | undefined {
|
|
83
|
+
const ctx = useContext(I18nContext)
|
|
84
|
+
return ctx?.t
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
export function useLocale() {
|
|
77
88
|
const ctx = useContext(I18nContext)
|
|
78
89
|
if (!ctx) throw new Error('useLocale must be used within I18nProvider')
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveRegisteredEntityTableName,
|
|
3
|
+
resolveEntityTableName,
|
|
4
|
+
isValidEntityIdShape,
|
|
5
|
+
ENTITY_ID_PATTERN,
|
|
6
|
+
} from '../engine'
|
|
7
|
+
|
|
8
|
+
function makeEm(metaByClass: Record<string, string>) {
|
|
9
|
+
const all = Object.entries(metaByClass).map(([className, tableName]) => ({ className, tableName }))
|
|
10
|
+
return {
|
|
11
|
+
getMetadata: () => ({
|
|
12
|
+
find: (className: string) => {
|
|
13
|
+
const tableName = metaByClass[className]
|
|
14
|
+
return tableName ? { tableName } : undefined
|
|
15
|
+
},
|
|
16
|
+
getAll: () => all,
|
|
17
|
+
}),
|
|
18
|
+
} as any
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('resolveRegisteredEntityTableName', () => {
|
|
22
|
+
it('resolves a registered entity via class-name metadata', () => {
|
|
23
|
+
const em = makeEm({ Todo: 'todos' })
|
|
24
|
+
expect(resolveRegisteredEntityTableName(em, 'example:todo')).toBe('todos')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('resolves a registered entity via the secondary table-name lookup', () => {
|
|
28
|
+
const em = makeEm({ SomeOtherClass: 'directory_organizations' })
|
|
29
|
+
expect(resolveRegisteredEntityTableName(em, 'directory:organization')).toBe('directory_organizations')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns null for an entity type that does not map to any registered metadata', () => {
|
|
33
|
+
const em = makeEm({ Todo: 'todos' })
|
|
34
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:auth_user')).toBeNull()
|
|
35
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:user')).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns null when no metadata is available', () => {
|
|
39
|
+
expect(resolveRegisteredEntityTableName(undefined, 'example:todo')).toBeNull()
|
|
40
|
+
expect(resolveRegisteredEntityTableName({} as any, 'example:todo')).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('never pluralizes attacker-chosen ids into a real table name', () => {
|
|
44
|
+
const em = makeEm({})
|
|
45
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:auth_user')).toBeNull()
|
|
46
|
+
expect(resolveRegisteredEntityTableName(em, 'foo:user')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('resolveEntityTableName fallback (broad query path)', () => {
|
|
51
|
+
it('still falls back to a pluralized guess for unregistered ids', () => {
|
|
52
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
53
|
+
try {
|
|
54
|
+
const em = makeEm({})
|
|
55
|
+
expect(resolveEntityTableName(em, 'foo:never_registered_widget')).toBe('never_registered_widgets')
|
|
56
|
+
} finally {
|
|
57
|
+
warnSpy.mockRestore()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('isValidEntityIdShape', () => {
|
|
63
|
+
it('accepts canonical module:entity ids', () => {
|
|
64
|
+
expect(isValidEntityIdShape('example:todo')).toBe(true)
|
|
65
|
+
expect(isValidEntityIdShape('directory:organization')).toBe(true)
|
|
66
|
+
expect(isValidEntityIdShape('query_index:search_token')).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects ids without exactly two snake_case segments', () => {
|
|
70
|
+
expect(isValidEntityIdShape('todos')).toBe(false)
|
|
71
|
+
expect(isValidEntityIdShape('auth_users')).toBe(false)
|
|
72
|
+
expect(isValidEntityIdShape('foo:bar:baz')).toBe(false)
|
|
73
|
+
expect(isValidEntityIdShape('Foo:Bar')).toBe(false)
|
|
74
|
+
expect(isValidEntityIdShape('1bad:entity')).toBe(false)
|
|
75
|
+
expect(isValidEntityIdShape('foo:')).toBe(false)
|
|
76
|
+
expect(isValidEntityIdShape(':bar')).toBe(false)
|
|
77
|
+
expect(isValidEntityIdShape('foo bar:baz')).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('exposes the pattern for schema reuse', () => {
|
|
81
|
+
expect(ENTITY_ID_PATTERN.test('example:todo')).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -42,6 +42,15 @@ type ResolvedCustomFieldSource = {
|
|
|
42
42
|
|
|
43
43
|
type ResultRow = Record<string, unknown>
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Canonical `module:entity` entity-id shape (both segments snake_case,
|
|
47
|
+
* starting with a lowercase letter). Used to validate caller-supplied entity
|
|
48
|
+
* ids at security-sensitive boundaries before they reach table resolution.
|
|
49
|
+
*/
|
|
50
|
+
export const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/
|
|
51
|
+
|
|
52
|
+
export const isValidEntityIdShape = (value: string): boolean => ENTITY_ID_PATTERN.test(value)
|
|
53
|
+
|
|
45
54
|
const pluralizeBaseName = (name: string): string => {
|
|
46
55
|
if (!name) return name
|
|
47
56
|
if (name.endsWith('s')) return name
|
|
@@ -65,46 +74,66 @@ const candidateClassNames = (rawName: string): string[] => {
|
|
|
65
74
|
return Array.from(candidates)
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Resolve an entity id to a table name strictly via registered MikroORM metadata.
|
|
79
|
+
*
|
|
80
|
+
* Unlike {@link resolveEntityTableName}, this never falls back to a pluralized
|
|
81
|
+
* guess: it returns `null` when no registered entity matches. Use it for
|
|
82
|
+
* security-sensitive call sites (e.g. the reindexer) that must refuse to read
|
|
83
|
+
* arbitrary, attacker-chosen tables that happen to exist in the schema.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveRegisteredEntityTableName(
|
|
86
|
+
em: EntityManager | undefined,
|
|
87
|
+
entity: EntityId,
|
|
88
|
+
): string | null {
|
|
72
89
|
const parts = String(entity || '').split(':')
|
|
73
90
|
const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
|
|
74
91
|
const metadata = (em as any)?.getMetadata?.()
|
|
75
92
|
|
|
76
|
-
if (metadata
|
|
77
|
-
const candidates = candidateClassNames(rawName)
|
|
78
|
-
for (const candidate of candidates) {
|
|
79
|
-
try {
|
|
80
|
-
const meta = metadata.find?.(candidate)
|
|
81
|
-
if (meta?.tableName) {
|
|
82
|
-
const tableName = String(meta.tableName)
|
|
83
|
-
entityTableCache.set(entity, tableName)
|
|
84
|
-
return tableName
|
|
85
|
-
}
|
|
86
|
-
} catch {}
|
|
87
|
-
}
|
|
93
|
+
if (!metadata || !rawName) return null
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const candidateTables = [
|
|
92
|
-
`${modulePrefix}_${rawName}`,
|
|
93
|
-
pluralizeBaseName(rawName),
|
|
94
|
-
`${modulePrefix}_${pluralizeBaseName(rawName)}`,
|
|
95
|
-
]
|
|
95
|
+
const candidates = candidateClassNames(rawName)
|
|
96
|
+
for (const candidate of candidates) {
|
|
96
97
|
try {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const tableName = String(meta.tableName)
|
|
101
|
-
entityTableCache.set(entity, tableName)
|
|
102
|
-
return tableName
|
|
103
|
-
}
|
|
98
|
+
const meta = metadata.find?.(candidate)
|
|
99
|
+
if (meta?.tableName) {
|
|
100
|
+
return String(meta.tableName)
|
|
104
101
|
}
|
|
105
102
|
} catch {}
|
|
106
103
|
}
|
|
107
104
|
|
|
105
|
+
// Secondary lookup: search ORM metadata by candidate table names
|
|
106
|
+
const modulePrefix = parts[0] ?? ''
|
|
107
|
+
const candidateTables = [
|
|
108
|
+
`${modulePrefix}_${rawName}`,
|
|
109
|
+
pluralizeBaseName(rawName),
|
|
110
|
+
`${modulePrefix}_${pluralizeBaseName(rawName)}`,
|
|
111
|
+
]
|
|
112
|
+
try {
|
|
113
|
+
const allMeta: any[] = metadata.getAll?.() ?? []
|
|
114
|
+
for (const meta of allMeta) {
|
|
115
|
+
if (meta?.tableName && candidateTables.includes(String(meta.tableName))) {
|
|
116
|
+
return String(meta.tableName)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveEntityTableName(em: EntityManager | undefined, entity: EntityId): string {
|
|
125
|
+
if (entityTableCache.has(entity)) {
|
|
126
|
+
return entityTableCache.get(entity)!
|
|
127
|
+
}
|
|
128
|
+
const parts = String(entity || '').split(':')
|
|
129
|
+
const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
|
|
130
|
+
|
|
131
|
+
const registered = resolveRegisteredEntityTableName(em, entity)
|
|
132
|
+
if (registered) {
|
|
133
|
+
entityTableCache.set(entity, registered)
|
|
134
|
+
return registered
|
|
135
|
+
}
|
|
136
|
+
|
|
108
137
|
const fallback = pluralizeBaseName(rawName || '')
|
|
109
138
|
console.warn(
|
|
110
139
|
`[QueryEngine] Could not resolve entity "${entity}" via ORM metadata. ` +
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
export type IntegrationScope = {
|
|
2
2
|
organizationId: string
|
|
3
3
|
tenantId: string
|
|
4
|
+
/**
|
|
5
|
+
* Optional per-user secret scoping. When set, credential lookups and writes
|
|
6
|
+
* are scoped to a single user — used by per-user channels (Gmail, IMAP)
|
|
7
|
+
* so two users on the same tenant don't share one row.
|
|
8
|
+
*
|
|
9
|
+
* When `undefined`/`null`, behaviour is unchanged — tenant-wide credentials
|
|
10
|
+
* (e.g. shared Stripe/Akeneo API keys) keep working exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* Stored on `IntegrationCredentials.user_id` (added 2026-05-26 by the email
|
|
13
|
+
* integration spec). Backed by the partial unique index
|
|
14
|
+
* `integration_credentials_user_lookup_idx` across integration, organization,
|
|
15
|
+
* tenant, and user where `user_id IS NOT NULL AND deleted_at IS NULL`.
|
|
16
|
+
*/
|
|
17
|
+
userId?: string | null
|
|
4
18
|
}
|
|
5
19
|
|
|
6
20
|
export type IntegrationCategory =
|
|
@@ -30,6 +30,13 @@ export type NotificationHandlerContext = {
|
|
|
30
30
|
userId?: string
|
|
31
31
|
features: string[]
|
|
32
32
|
currentPath: string
|
|
33
|
+
/**
|
|
34
|
+
* Optional translator, present when the dispatch happens inside a React tree
|
|
35
|
+
* with an active `I18nProvider` (the poll/SSE notification hooks). Handlers
|
|
36
|
+
* that emit user-facing copy (e.g. a toast action label) MUST localize via
|
|
37
|
+
* this when available and fall back to an English literal only when absent.
|
|
38
|
+
*/
|
|
39
|
+
t?: (key: string, fallback?: string) => string
|
|
33
40
|
toast: (options: NotificationHandlerToastOptions) => void
|
|
34
41
|
popup: (options: NotificationHandlerPopupOptions) => void
|
|
35
42
|
emitEvent: (eventName: string, detail?: unknown) => void
|
package/src/modules/search.ts
CHANGED
|
@@ -279,6 +279,15 @@ export type SearchEntityConfig = {
|
|
|
279
279
|
resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null
|
|
280
280
|
/** Define which fields are searchable vs hash-only */
|
|
281
281
|
fieldPolicy?: SearchFieldPolicy
|
|
282
|
+
/**
|
|
283
|
+
* Per-entity view feature(s) required to read this entity's records through
|
|
284
|
+
* data-returning surfaces (e.g. the `search_get` / `search_aggregate` AI tools).
|
|
285
|
+
* These tools must NOT rely on the search-administration `search.view` feature
|
|
286
|
+
* to gate record reads — callers must additionally hold the owning module's
|
|
287
|
+
* `<entity>.view` feature(s) declared here. When omitted, those tools fail
|
|
288
|
+
* closed (deny) so an entity is never exposed without an explicit grant.
|
|
289
|
+
*/
|
|
290
|
+
aclFeatures?: string[]
|
|
282
291
|
}
|
|
283
292
|
|
|
284
293
|
/**
|
package/src/modules/vector.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityId } from './entities'
|
|
2
2
|
import type { QueryEngine } from '../lib/query/types'
|
|
3
|
+
import type { SearchFieldPolicy } from './search'
|
|
3
4
|
|
|
4
5
|
export type VectorDriverId = 'pgvector' | 'qdrant' | 'chromadb'
|
|
5
6
|
|
|
@@ -71,6 +72,12 @@ export type VectorEntityConfig = {
|
|
|
71
72
|
* Provide extra deep links rendered next to the search result.
|
|
72
73
|
*/
|
|
73
74
|
resolveLinks?: (ctx: VectorBuildContext) => Promise<VectorLinkDescriptor[] | null> | VectorLinkDescriptor[] | null
|
|
75
|
+
/**
|
|
76
|
+
* Controls which record/custom fields are eligible for the default embedding source builder.
|
|
77
|
+
* Applied only when `buildSource` is not provided. `excluded`/`hashOnly` fields are never embedded;
|
|
78
|
+
* when `searchable` is defined it acts as an allowlist for both record and custom fields.
|
|
79
|
+
*/
|
|
80
|
+
fieldPolicy?: SearchFieldPolicy
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export type VectorModuleConfig = {
|