@open-mercato/shared 0.8.1-develop.7206.1.45a3cee87f → 0.8.1-develop.7215.1.60786cf836
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/dist/lib/encryption/tenantDataEncryptionService.js +141 -30
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/widgets/extension-points.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +137 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +168 -32
- package/src/lib/query/engine.ts +8 -7
- package/src/lib/search/config.ts +7 -0
- package/src/modules/widgets/extension-points.ts +9 -1
|
@@ -34,6 +34,10 @@ const MAP_MISS_TTL_MS = 5 * 60 * 1000
|
|
|
34
34
|
// up by long-lived processes without a restart (#2746). The service-level cache
|
|
35
35
|
// previously had no TTL and shadowed the KMS's own 15-minute expiry.
|
|
36
36
|
const DEK_CACHE_TTL_MS = 15 * 60 * 1000
|
|
37
|
+
// Matches the `{ ttl: 300 }` passed to the `CacheStrategy` layer for the all-organizations
|
|
38
|
+
// aggregate, so a process that only ever hits its own memory entry (no shared cache configured,
|
|
39
|
+
// or already warm) still picks up a runtime map edit within the same bound (#6066).
|
|
40
|
+
const AGGREGATE_CACHE_TTL_MS = 300 * 1000
|
|
37
41
|
|
|
38
42
|
function cacheKey(key: MapCacheKey): string {
|
|
39
43
|
return [
|
|
@@ -44,6 +48,13 @@ function cacheKey(key: MapCacheKey): string {
|
|
|
44
48
|
].join(':')
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
// Tag for the aggregate of every organization-scoped map of an entity/tenant. The prefix differs
|
|
52
|
+
// from `cacheKey`'s in its first segment rather than by an extra one, so no entity id can spell a
|
|
53
|
+
// tag that collides with an aggregate (`encmap:all-orgs:x:y` would be reachable both ways).
|
|
54
|
+
function allOrganizationsCacheKey(entityId: string, tenantId: string | null): string {
|
|
55
|
+
return ['encmap-all-orgs', entityId.toLowerCase(), tenantId ?? 'null'].join(':')
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
function debug(event: string, payload: Record<string, unknown>) {
|
|
48
59
|
if (!isEncryptionDebugEnabled()) return
|
|
49
60
|
try {
|
|
@@ -113,11 +124,45 @@ function isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {
|
|
|
113
124
|
return decryptWithAesGcm(value, dek.key) !== null
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
function
|
|
127
|
+
function normalizeEncryptedFieldRules(
|
|
128
|
+
fields: readonly { field?: unknown; hashField?: unknown }[] | null | undefined,
|
|
129
|
+
): EncryptedFieldRule[] {
|
|
117
130
|
if (!Array.isArray(fields)) return []
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
const rules: EncryptedFieldRule[] = []
|
|
132
|
+
for (const rule of fields) {
|
|
133
|
+
if (!rule || typeof rule !== 'object') continue
|
|
134
|
+
const field = rule.field
|
|
135
|
+
if (typeof field !== 'string' || field.trim().length === 0) continue
|
|
136
|
+
rules.push({ field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null })
|
|
137
|
+
}
|
|
138
|
+
return rules
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {
|
|
142
|
+
return normalizeEncryptedFieldRules(fields).map((rule) => rule.field)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Union of field rules, first declaration wins on the field name. A later duplicate only
|
|
147
|
+
* contributes its `hashField` when the winning rule declares none, so merging an organization-scoped
|
|
148
|
+
* map into a tenant-wide one never silently retargets an existing lookup-hash column.
|
|
149
|
+
*/
|
|
150
|
+
function mergeEncryptedFieldRules(groups: readonly EncryptedFieldRule[][]): EncryptedFieldRule[] {
|
|
151
|
+
const merged: EncryptedFieldRule[] = []
|
|
152
|
+
const byField = new Map<string, EncryptedFieldRule>()
|
|
153
|
+
for (const group of groups) {
|
|
154
|
+
for (const rule of group) {
|
|
155
|
+
const existing = byField.get(rule.field)
|
|
156
|
+
if (!existing) {
|
|
157
|
+
const copy: EncryptedFieldRule = { field: rule.field, hashField: rule.hashField ?? null }
|
|
158
|
+
byField.set(rule.field, copy)
|
|
159
|
+
merged.push(copy)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (!existing.hashField && rule.hashField) existing.hashField = rule.hashField
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return merged
|
|
121
166
|
}
|
|
122
167
|
|
|
123
168
|
function readEncryptedFieldsJson(row: Record<string, unknown>): EncryptedFieldRule[] {
|
|
@@ -160,6 +205,7 @@ function getSqlConnection(em: EntityManager): SqlConnection | null {
|
|
|
160
205
|
|
|
161
206
|
export class TenantDataEncryptionService {
|
|
162
207
|
private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
|
|
208
|
+
private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()
|
|
163
209
|
private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
|
|
164
210
|
private static globalDekCache = new Map<string, TenantDek>()
|
|
165
211
|
private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()
|
|
@@ -167,6 +213,7 @@ export class TenantDataEncryptionService {
|
|
|
167
213
|
private readonly kms: KmsService
|
|
168
214
|
private readonly cache?: CacheStrategy
|
|
169
215
|
private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
|
|
216
|
+
private readonly aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache
|
|
170
217
|
private readonly dekCache = TenantDataEncryptionService.globalDekCache
|
|
171
218
|
private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
|
|
172
219
|
private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
|
|
@@ -352,7 +399,10 @@ export class TenantDataEncryptionService {
|
|
|
352
399
|
return this.applySystemDefault(null, key.entityId)
|
|
353
400
|
}
|
|
354
401
|
|
|
355
|
-
private async
|
|
402
|
+
private async fetchAllOrganizationFieldRules(
|
|
403
|
+
entityId: string,
|
|
404
|
+
tenantId: string | null,
|
|
405
|
+
): Promise<EncryptedFieldRule[]> {
|
|
356
406
|
const conn = getSqlConnection(this.em)
|
|
357
407
|
if (!conn) return []
|
|
358
408
|
const sql = `
|
|
@@ -366,23 +416,97 @@ export class TenantDataEncryptionService {
|
|
|
366
416
|
`
|
|
367
417
|
const rows = await conn.execute(sql, [entityId, tenantId])
|
|
368
418
|
if (!Array.isArray(rows) || rows.length === 0) return []
|
|
369
|
-
const
|
|
419
|
+
const groups: EncryptedFieldRule[][] = []
|
|
370
420
|
for (const row of rows) {
|
|
371
421
|
if (!row || typeof row !== 'object') continue
|
|
372
|
-
|
|
373
|
-
|
|
422
|
+
groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))
|
|
423
|
+
}
|
|
424
|
+
return mergeEncryptedFieldRules(groups)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`
|
|
429
|
+
* and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached
|
|
430
|
+
* read this used to be would add a round-trip per flush and per load (#5949).
|
|
431
|
+
*/
|
|
432
|
+
private async getAllOrganizationFieldRules(
|
|
433
|
+
entityId: string,
|
|
434
|
+
tenantId: string | null,
|
|
435
|
+
): Promise<EncryptedFieldRule[]> {
|
|
436
|
+
const tag = allOrganizationsCacheKey(entityId, tenantId)
|
|
437
|
+
const missExpiresAt = this.missCache.get(tag)
|
|
438
|
+
if (missExpiresAt) {
|
|
439
|
+
if (missExpiresAt > Date.now()) return []
|
|
440
|
+
this.missCache.delete(tag)
|
|
441
|
+
}
|
|
442
|
+
const mem = this.aggregateMemoryCache.get(tag)
|
|
443
|
+
if (mem) {
|
|
444
|
+
if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields
|
|
445
|
+
this.aggregateMemoryCache.delete(tag)
|
|
446
|
+
}
|
|
447
|
+
if (this.cache && typeof this.cache.get === 'function') {
|
|
448
|
+
const cached = await this.cache.get(tag)
|
|
449
|
+
if (cached) {
|
|
450
|
+
const record = cached as EncryptionMapRecord
|
|
451
|
+
this.aggregateMemoryCache.set(tag, { at: Date.now(), record })
|
|
452
|
+
return record.fields
|
|
374
453
|
}
|
|
375
454
|
}
|
|
376
|
-
|
|
455
|
+
const inflight = this.inflightMaps.get(tag)
|
|
456
|
+
if (inflight) return (await inflight)?.fields ?? []
|
|
457
|
+
const pending = (async (): Promise<EncryptionMapRecord | null> => {
|
|
458
|
+
const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)
|
|
459
|
+
return fields.length ? { entityId, fields } : null
|
|
460
|
+
})()
|
|
461
|
+
this.inflightMaps.set(tag, pending)
|
|
462
|
+
let loaded: EncryptionMapRecord | null
|
|
463
|
+
try {
|
|
464
|
+
loaded = await pending
|
|
465
|
+
} finally {
|
|
466
|
+
this.inflightMaps.delete(tag)
|
|
467
|
+
}
|
|
468
|
+
if (!loaded) {
|
|
469
|
+
this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
|
|
470
|
+
return []
|
|
471
|
+
}
|
|
472
|
+
this.missCache.delete(tag)
|
|
473
|
+
this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded })
|
|
474
|
+
if (this.cache && typeof this.cache.set === 'function') {
|
|
475
|
+
await this.cache.set(tag, loaded, { ttl: 300 })
|
|
476
|
+
}
|
|
477
|
+
return loaded.fields
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The field rules that are actually encrypted at rest for a scope.
|
|
482
|
+
*
|
|
483
|
+
* At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but
|
|
484
|
+
* rows of the same entity may carry fields declared solely by an organization-scoped map. Reading
|
|
485
|
+
* or writing such a row with the base map alone stores those fields as plaintext and hands
|
|
486
|
+
* callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in
|
|
487
|
+
* (#5949) — the same set `getEncryptedFieldNames` has reported since #2282.
|
|
488
|
+
*/
|
|
489
|
+
private async resolveFieldRulesForScope(
|
|
490
|
+
entityId: string,
|
|
491
|
+
tenantId: string | null,
|
|
492
|
+
organizationId: string | null,
|
|
493
|
+
map: EncryptionMapRecord | null,
|
|
494
|
+
): Promise<EncryptedFieldRule[]> {
|
|
495
|
+
const mapRules = normalizeEncryptedFieldRules(map?.fields)
|
|
496
|
+
if (organizationId != null) return mapRules
|
|
497
|
+
return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)])
|
|
377
498
|
}
|
|
378
499
|
|
|
379
500
|
async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
501
|
+
const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)]
|
|
502
|
+
for (const tag of tags) {
|
|
503
|
+
this.memoryCache.delete(tag)
|
|
504
|
+
this.aggregateMemoryCache.delete(tag)
|
|
505
|
+
this.inflightMaps.delete(tag)
|
|
506
|
+
this.missCache.delete(tag)
|
|
507
|
+
if (this.cache && typeof (this.cache as any).delete === 'function') {
|
|
508
|
+
await (this.cache as any).delete(tag)
|
|
509
|
+
}
|
|
386
510
|
}
|
|
387
511
|
}
|
|
388
512
|
|
|
@@ -416,13 +540,13 @@ export class TenantDataEncryptionService {
|
|
|
416
540
|
return []
|
|
417
541
|
}
|
|
418
542
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
419
|
-
const fields =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
return
|
|
543
|
+
const fields = await this.resolveFieldRulesForScope(
|
|
544
|
+
entityId,
|
|
545
|
+
tenantId ?? null,
|
|
546
|
+
organizationId ?? null,
|
|
547
|
+
map,
|
|
548
|
+
)
|
|
549
|
+
return fields.map((rule) => rule.field)
|
|
426
550
|
}
|
|
427
551
|
|
|
428
552
|
private encryptFields(
|
|
@@ -506,18 +630,24 @@ export class TenantDataEncryptionService {
|
|
|
506
630
|
return payload
|
|
507
631
|
}
|
|
508
632
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
509
|
-
|
|
633
|
+
const fields = await this.resolveFieldRulesForScope(
|
|
634
|
+
entityId,
|
|
635
|
+
tenantId ?? null,
|
|
636
|
+
organizationId ?? null,
|
|
637
|
+
map,
|
|
638
|
+
)
|
|
639
|
+
if (!fields.length) {
|
|
510
640
|
debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
|
|
511
641
|
return payload
|
|
512
642
|
}
|
|
513
|
-
const keyId = resolveEncryptionKeyId(entityId, map
|
|
643
|
+
const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)
|
|
514
644
|
const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false })
|
|
515
645
|
if (!dek) {
|
|
516
|
-
debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId, keyScope: map
|
|
646
|
+
debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })
|
|
517
647
|
return payload
|
|
518
648
|
}
|
|
519
|
-
debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields:
|
|
520
|
-
return this.encryptFields(payload,
|
|
649
|
+
debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })
|
|
650
|
+
return this.encryptFields(payload, fields, dek)
|
|
521
651
|
}
|
|
522
652
|
|
|
523
653
|
async decryptEntityPayload(
|
|
@@ -531,17 +661,23 @@ export class TenantDataEncryptionService {
|
|
|
531
661
|
return payload
|
|
532
662
|
}
|
|
533
663
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
534
|
-
|
|
664
|
+
const fields = await this.resolveFieldRulesForScope(
|
|
665
|
+
entityId,
|
|
666
|
+
tenantId ?? null,
|
|
667
|
+
organizationId ?? null,
|
|
668
|
+
map,
|
|
669
|
+
)
|
|
670
|
+
if (!fields.length) {
|
|
535
671
|
debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
|
|
536
672
|
return payload
|
|
537
673
|
}
|
|
538
|
-
const keyId = resolveEncryptionKeyId(entityId, map
|
|
674
|
+
const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)
|
|
539
675
|
const dek = await this.getDek(keyId)
|
|
540
676
|
if (!dek) {
|
|
541
|
-
debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId, keyScope: map
|
|
677
|
+
debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })
|
|
542
678
|
return payload
|
|
543
679
|
}
|
|
544
|
-
debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields:
|
|
545
|
-
return this.decryptFields(payload,
|
|
680
|
+
debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })
|
|
681
|
+
return this.decryptFields(payload, fields, dek)
|
|
546
682
|
}
|
|
547
683
|
}
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -60,9 +60,10 @@ export function isEncryptedLikeField(encrypted: ReadonlySet<string>, field: stri
|
|
|
60
60
|
return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate))
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
// The all-orgs union behind `getEncryptedFieldNames(..., organizationId: null)`
|
|
64
|
-
// `encryption_maps
|
|
65
|
-
//
|
|
63
|
+
// The all-orgs union behind `getEncryptedFieldNames(..., organizationId: null)` reads
|
|
64
|
+
// `encryption_maps`. The service caches that aggregate since #5949, but this set is per-search
|
|
65
|
+
// hot and also spans the name-shape expansion below, so a short TTL keeps the whole resolved
|
|
66
|
+
// set out of the request path without meaningfully delaying a map rollout.
|
|
66
67
|
const ENCRYPTED_LIKE_FIELDS_TTL_MS = 60_000
|
|
67
68
|
const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500
|
|
68
69
|
const encryptedLikeFieldsCache = new Map<string, { at: number; fields: Set<string> }>()
|
|
@@ -455,10 +456,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
455
456
|
// `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while the
|
|
456
457
|
// KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
|
|
457
458
|
// `organizationId: null` is deliberate, not an omission: the service then unions in every
|
|
458
|
-
// organization's map
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
459
|
+
// organization's map, so a field any org encrypts stays on the token path -- a wider set
|
|
460
|
+
// fails safe. Passing the request's org instead would silently break encrypted-column search
|
|
461
|
+
// for orgs without their own map. The service caches that union (#5949), and the TTL cache
|
|
462
|
+
// above keeps even the cache lookup off the request path. `null` means
|
|
462
463
|
// the encryption service could not answer at all; keep the pre-existing rewrite-everything
|
|
463
464
|
// behavior then, because guessing "plaintext" would turn encrypted-column search into an
|
|
464
465
|
// ILIKE-on-ciphertext that matches nothing.
|
package/src/lib/search/config.ts
CHANGED
|
@@ -21,6 +21,13 @@ export type SearchConfig = {
|
|
|
21
21
|
entityBlocklistedFields?: Record<string, string[]>
|
|
22
22
|
maxFieldChars?: number
|
|
23
23
|
maxTokensPerField?: number
|
|
24
|
+
/**
|
|
25
|
+
* Ceiling on token rows across all fields of one record; `0` disables it. The budget is spent in
|
|
26
|
+
* the order the document's own keys iterate in, so on an over-budget record *which* fields stay
|
|
27
|
+
* searchable depends on that key order — see `buildSearchTokenRows` in
|
|
28
|
+
* `@open-mercato/core/modules/query_index/lib/search-tokens` before recomputing expected tokens
|
|
29
|
+
* from a document that did not come straight from the indexer.
|
|
30
|
+
*/
|
|
24
31
|
maxTokensPerRecord?: number
|
|
25
32
|
}
|
|
26
33
|
|
|
@@ -58,7 +58,13 @@ export type ExtensionPointPatternParameter = {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
type ExtensionHostDeclarationBase = {
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* File(s) this host is mounted from, relative to the module's source root. A
|
|
63
|
+
* host mounted from more than one file may declare them all; the first entry
|
|
64
|
+
* remains the authoritative provenance path, the rest surface on the
|
|
65
|
+
* generated fact's `additionalSources`.
|
|
66
|
+
*/
|
|
67
|
+
source: string | readonly string[]
|
|
62
68
|
contextContract?: string
|
|
63
69
|
dataContract?: string
|
|
64
70
|
scopeContract?: string
|
|
@@ -398,6 +404,8 @@ export type ModuleExtensionHostFact = {
|
|
|
398
404
|
| { kind: 'declaration'; path: string; symbol: string }
|
|
399
405
|
| { kind: 'fact-ref'; factSection: string; factKey: string }
|
|
400
406
|
| { kind: 'framework'; path: string; symbol: string }
|
|
407
|
+
/** Extra mount-point paths beyond the declaration's primary `source` entry. */
|
|
408
|
+
additionalSources?: string[]
|
|
401
409
|
aliases?: string[]
|
|
402
410
|
patternParameters?: Record<string, ExtensionPointPatternParameter>
|
|
403
411
|
fallbacks?: string[]
|