@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +12 -5
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- package/src/modules/registry.ts +4 -1
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import { getEnrichersForEntity } from './enricher-registry'
|
|
16
16
|
import { logEnricherTiming } from '../umes/enricher-timing'
|
|
17
17
|
import { createLogger } from '../logger'
|
|
18
|
+
import { getTelemetryRuntime } from '../telemetry/runtime'
|
|
18
19
|
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
19
20
|
|
|
20
21
|
const logger = createLogger('shared').child({ component: 'umes' })
|
|
@@ -22,7 +23,82 @@ const logger = createLogger('shared').child({ component: 'umes' })
|
|
|
22
23
|
const DEFAULT_TIMEOUT = 2000
|
|
23
24
|
const SLOW_WARN_MS = 100
|
|
24
25
|
const SLOW_ERROR_MS = 500
|
|
26
|
+
const LOG_THROTTLE_INTERVAL_MS = 30_000
|
|
25
27
|
const DEFAULT_CACHE_TTL_MS = 60_000
|
|
28
|
+
const ENRICHER_DURATION_METRIC = 'om.enricher.duration'
|
|
29
|
+
|
|
30
|
+
type SlowLogState = {
|
|
31
|
+
lastLoggedAt: number
|
|
32
|
+
suppressedCount: number
|
|
33
|
+
maxElapsedMs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const slowLogStateByEnricher = new Map<string, SlowLogState>()
|
|
37
|
+
const metricFailureLoggedAtByEnricher = new Map<string, number>()
|
|
38
|
+
|
|
39
|
+
function logMetricRecordingFailure(enricherId: string, err: unknown, failedAt: number): void {
|
|
40
|
+
const lastLoggedAt = metricFailureLoggedAtByEnricher.get(enricherId)
|
|
41
|
+
if (lastLoggedAt !== undefined && failedAt - lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) return
|
|
42
|
+
metricFailureLoggedAtByEnricher.set(enricherId, failedAt)
|
|
43
|
+
logger.warn('Enricher duration metric recording failed', {
|
|
44
|
+
enricherId,
|
|
45
|
+
metric: ENRICHER_DURATION_METRIC,
|
|
46
|
+
err,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function recordEnricherDuration(
|
|
51
|
+
enricherId: string,
|
|
52
|
+
moduleId: string,
|
|
53
|
+
targetEntity: string,
|
|
54
|
+
elapsedMs: number,
|
|
55
|
+
finishedAt: number,
|
|
56
|
+
): void {
|
|
57
|
+
logEnricherTiming(enricherId, moduleId, targetEntity, elapsedMs)
|
|
58
|
+
try {
|
|
59
|
+
getTelemetryRuntime()?.recordHistogram?.(
|
|
60
|
+
ENRICHER_DURATION_METRIC,
|
|
61
|
+
elapsedMs / 1000,
|
|
62
|
+
{ 'enricher.id': enricherId },
|
|
63
|
+
's',
|
|
64
|
+
)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
logMetricRecordingFailure(enricherId, err, finishedAt)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function logSlowEnricher(enricherId: string, elapsedMs: number, finishedAt: number): void {
|
|
71
|
+
if (elapsedMs <= SLOW_WARN_MS) return
|
|
72
|
+
|
|
73
|
+
if (elapsedMs <= SLOW_ERROR_MS) {
|
|
74
|
+
logger.debug('Enricher exceeded slow threshold', {
|
|
75
|
+
enricherId,
|
|
76
|
+
elapsedMs,
|
|
77
|
+
thresholdMs: SLOW_WARN_MS,
|
|
78
|
+
})
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const state = slowLogStateByEnricher.get(enricherId)
|
|
83
|
+
if (state && finishedAt - state.lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) {
|
|
84
|
+
state.suppressedCount += 1
|
|
85
|
+
state.maxElapsedMs = Math.max(state.maxElapsedMs, elapsedMs)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
logger.error('Enricher exceeded slow threshold', {
|
|
90
|
+
enricherId,
|
|
91
|
+
elapsedMs,
|
|
92
|
+
thresholdMs: SLOW_ERROR_MS,
|
|
93
|
+
suppressedCount: state?.suppressedCount ?? 0,
|
|
94
|
+
maxElapsedMs: Math.max(state?.maxElapsedMs ?? 0, elapsedMs),
|
|
95
|
+
})
|
|
96
|
+
slowLogStateByEnricher.set(enricherId, {
|
|
97
|
+
lastLoggedAt: finishedAt,
|
|
98
|
+
suppressedCount: 0,
|
|
99
|
+
maxElapsedMs: 0,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
26
102
|
|
|
27
103
|
function timeoutPromise(ms: number): Promise<never> {
|
|
28
104
|
return new Promise((_, reject) =>
|
|
@@ -378,13 +454,10 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
|
|
|
378
454
|
)
|
|
379
455
|
}
|
|
380
456
|
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
logger.warn('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_WARN_MS })
|
|
386
|
-
}
|
|
387
|
-
logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)
|
|
457
|
+
const finishedAt = Date.now()
|
|
458
|
+
const elapsedMs = finishedAt - startTime
|
|
459
|
+
logSlowEnricher(enricher.id, elapsedMs, finishedAt)
|
|
460
|
+
recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt)
|
|
388
461
|
|
|
389
462
|
currentItems = result
|
|
390
463
|
if (shouldUseCache && cacheKey) {
|
|
@@ -482,8 +555,9 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
|
|
|
482
555
|
timeoutPromise(timeout),
|
|
483
556
|
])
|
|
484
557
|
|
|
485
|
-
const
|
|
486
|
-
|
|
558
|
+
const finishedAt = Date.now()
|
|
559
|
+
const elapsedMs = finishedAt - startTime
|
|
560
|
+
recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt)
|
|
487
561
|
|
|
488
562
|
currentRecord = result
|
|
489
563
|
if (shouldUseCache && cacheKey) {
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCache
|
|
|
69
69
|
import type { EnricherContext } from './response-enricher'
|
|
70
70
|
import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
|
|
71
71
|
import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
|
|
72
|
+
import { resolveApiInterceptorRoutePath } from '../modules/api-route-identity'
|
|
72
73
|
import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
|
|
73
74
|
import { buildQueryParams } from './query-params'
|
|
74
75
|
import { mergeAdvancedFilters } from './advanced-filter-integration'
|
|
@@ -790,17 +791,6 @@ function snapshotEntity(entity: unknown): Record<string, unknown> | undefined {
|
|
|
790
791
|
return safeClone(entity) as Record<string, unknown>
|
|
791
792
|
}
|
|
792
793
|
|
|
793
|
-
function normalizeInterceptorRoutePath(request: Request): string {
|
|
794
|
-
try {
|
|
795
|
-
const pathname = new URL(request.url).pathname
|
|
796
|
-
if (pathname.startsWith('/api/')) return pathname.slice(5)
|
|
797
|
-
if (pathname === '/api') return ''
|
|
798
|
-
return pathname.replace(/^\/+/, '')
|
|
799
|
-
} catch {
|
|
800
|
-
return ''
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
794
|
function toInterceptorHeaders(headers: Headers): Record<string, string> {
|
|
805
795
|
const output: Record<string, string> = {}
|
|
806
796
|
headers.forEach((value, key) => {
|
|
@@ -1029,7 +1019,7 @@ function safeClone<T>(value: T): T {
|
|
|
1029
1019
|
}
|
|
1030
1020
|
|
|
1031
1021
|
function collectScopeOrganizationIds(ctx: CrudCtx): Array<string | null> {
|
|
1032
|
-
if (Array.isArray(ctx.organizationIds)
|
|
1022
|
+
if (Array.isArray(ctx.organizationIds)) {
|
|
1033
1023
|
return Array.from(new Set(ctx.organizationIds))
|
|
1034
1024
|
}
|
|
1035
1025
|
const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
@@ -1059,7 +1049,7 @@ function buildCrudCacheKey(
|
|
|
1059
1049
|
const scopeIds = collectScopeOrganizationIds(ctx)
|
|
1060
1050
|
const scopeSegment = scopeIds.length
|
|
1061
1051
|
? scopeIds.map((id) => normalizeTagSegment(id)).sort((a, b) => a.localeCompare(b)).join(',')
|
|
1062
|
-
: '
|
|
1052
|
+
: 'empty'
|
|
1063
1053
|
const segments = [
|
|
1064
1054
|
'crud',
|
|
1065
1055
|
normalizeTagSegment(resource),
|
|
@@ -1417,12 +1407,20 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1417
1407
|
if (!interceptorContext) {
|
|
1418
1408
|
return { errorResponse: null, requestPayload, metadataByInterceptor: {} }
|
|
1419
1409
|
}
|
|
1410
|
+
const routePath = resolveApiInterceptorRoutePath(args.request)
|
|
1411
|
+
if (routePath === null) {
|
|
1412
|
+
return {
|
|
1413
|
+
errorResponse: json({ error: 'Bad request' }, { status: 400 }),
|
|
1414
|
+
requestPayload,
|
|
1415
|
+
metadataByInterceptor: {},
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1420
1418
|
const contextWithHeaders = {
|
|
1421
1419
|
...interceptorContext,
|
|
1422
1420
|
extensionHeaders: parseExtensionHeaders(requestPayload.headers),
|
|
1423
1421
|
}
|
|
1424
1422
|
const result = await runApiInterceptorsBefore({
|
|
1425
|
-
routePath
|
|
1423
|
+
routePath,
|
|
1426
1424
|
method: args.method,
|
|
1427
1425
|
request: requestPayload,
|
|
1428
1426
|
context: contextWithHeaders,
|
|
@@ -1445,8 +1443,12 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1445
1443
|
}): Promise<{ ok: boolean; statusCode: number; body: Record<string, unknown>; headers: Record<string, string> } | null> {
|
|
1446
1444
|
const interceptorContext = await buildInterceptorContext(args.ctx)
|
|
1447
1445
|
if (!interceptorContext) return { ok: true, statusCode: args.statusCode, body: args.body, headers: args.headers ?? {} }
|
|
1446
|
+
const routePath = resolveApiInterceptorRoutePath(args.request)
|
|
1447
|
+
if (routePath === null) {
|
|
1448
|
+
return { ok: false, statusCode: 400, body: { error: 'Bad request' }, headers: {} }
|
|
1449
|
+
}
|
|
1448
1450
|
const result = await runApiInterceptorsAfter({
|
|
1449
|
-
routePath
|
|
1451
|
+
routePath,
|
|
1450
1452
|
method: args.method,
|
|
1451
1453
|
request: args.requestPayload,
|
|
1452
1454
|
response: {
|
|
@@ -1523,10 +1525,16 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1523
1525
|
orgId: scopedOrgId ?? null,
|
|
1524
1526
|
}
|
|
1525
1527
|
: null
|
|
1526
|
-
const fallbackOrgId = scopedOrgId
|
|
1528
|
+
const fallbackOrgId = scope ? scopedOrgId : (rawAuth?.orgId ?? null)
|
|
1529
|
+
const hasExplicitEmptyScope = Boolean(scope && (
|
|
1530
|
+
(Array.isArray(scope.filterIds) && scope.filterIds.length === 0)
|
|
1531
|
+
|| (Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0)
|
|
1532
|
+
))
|
|
1527
1533
|
const rawScopeIds = scope?.filterIds
|
|
1528
1534
|
const scopedIds = Array.isArray(rawScopeIds) ? rawScopeIds.filter((id): id is string => typeof id === 'string' && id.length > 0) : null
|
|
1529
|
-
if (
|
|
1535
|
+
if (hasExplicitEmptyScope) {
|
|
1536
|
+
organizationIds = []
|
|
1537
|
+
} else if (!scope) {
|
|
1530
1538
|
organizationIds = fallbackOrgId ? [fallbackOrgId] : null
|
|
1531
1539
|
} else if (scopedIds === null) {
|
|
1532
1540
|
organizationIds = scope.allowedIds === null ? null : (fallbackOrgId ? [fallbackOrgId] : null)
|
|
@@ -1537,7 +1545,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1537
1545
|
let canUseFallback = false
|
|
1538
1546
|
if (allowedIds === null) {
|
|
1539
1547
|
canUseFallback = true
|
|
1540
|
-
} else if (allowedIds.includes(fallbackOrgId)
|
|
1548
|
+
} else if (allowedIds.includes(fallbackOrgId)) {
|
|
1541
1549
|
canUseFallback = true
|
|
1542
1550
|
}
|
|
1543
1551
|
if (canUseFallback) {
|
|
@@ -1629,6 +1637,25 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1629
1637
|
let validated = opts.list.schema.parse(rawQueryParams)
|
|
1630
1638
|
profiler.mark('query_validated')
|
|
1631
1639
|
|
|
1640
|
+
if (ormCfg.orgField && Array.isArray(ctx.organizationIds) && ctx.organizationIds.length === 0) {
|
|
1641
|
+
profiler.mark('scope_blocked')
|
|
1642
|
+
logForbidden({
|
|
1643
|
+
resourceKind,
|
|
1644
|
+
action: 'list',
|
|
1645
|
+
reason: 'organization_scope_empty',
|
|
1646
|
+
userId: ctx.auth?.sub ?? null,
|
|
1647
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1648
|
+
organizationIds: ctx.organizationIds,
|
|
1649
|
+
})
|
|
1650
|
+
const page = Number((validated as Record<string, unknown>).page ?? 1) || 1
|
|
1651
|
+
const pageSize = Math.min(
|
|
1652
|
+
Math.max(Number((validated as Record<string, unknown>).pageSize ?? 50) || 50, 1),
|
|
1653
|
+
100,
|
|
1654
|
+
)
|
|
1655
|
+
finishProfile({ result: 'scope_blocked', itemCount: 0, total: 0 })
|
|
1656
|
+
return json({ items: [], total: 0, page, pageSize, totalPages: 0 })
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1632
1659
|
const beforeInterceptors = await applyInterceptorsBefore({
|
|
1633
1660
|
ctx,
|
|
1634
1661
|
request,
|
|
@@ -1695,7 +1722,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1695
1722
|
if (Array.isArray(payload)) return
|
|
1696
1723
|
const items = Array.isArray((payload as any).items) ? (payload as any).items : []
|
|
1697
1724
|
const tags = new Set<string>()
|
|
1698
|
-
const scopeOrgIds = collectScopeOrganizationIds(ctx)
|
|
1725
|
+
const scopeOrgIds = ormCfg.orgField ? collectScopeOrganizationIds(ctx) : [null]
|
|
1699
1726
|
const crudSegment = deriveCrudSegmentTag(resourceKind, request)
|
|
1700
1727
|
for (const target of resourceTargets) {
|
|
1701
1728
|
for (const tag of buildCollectionTags(target, tenantForScope, scopeOrgIds)) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { registerEntityIds } from '../../encryption/entityIds'
|
|
2
|
+
|
|
3
|
+
const setRecordCustomFieldsMock = jest.fn(async () => undefined)
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
|
|
6
|
+
setRecordCustomFields: (...args: unknown[]) => setRecordCustomFieldsMock(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
10
|
+
const { DefaultDataEngine, clearDataEngineTableExistsCache } = require('../engine')
|
|
11
|
+
|
|
12
|
+
const ENTITY_ID = 'example:todo'
|
|
13
|
+
const RECORD_ID = '11111111-1111-4111-8111-111111111111'
|
|
14
|
+
|
|
15
|
+
function buildDb() {
|
|
16
|
+
const chain = {
|
|
17
|
+
select: (_s: unknown) => chain,
|
|
18
|
+
values: (_v: unknown) => chain,
|
|
19
|
+
set: (_v: unknown) => chain,
|
|
20
|
+
where: (_c: string, _o: string, _v: unknown) => chain,
|
|
21
|
+
onConflict: (callback: (builder: { columns: (c: string[]) => { doUpdateSet: (v: unknown) => unknown } }) => unknown) => {
|
|
22
|
+
callback({ columns: (_c) => ({ doUpdateSet: (_v) => ({}) }) })
|
|
23
|
+
return chain
|
|
24
|
+
},
|
|
25
|
+
executeTakeFirst: async () => ({ present: 1, doc: { id: RECORD_ID }, numUpdatedRows: 1n }),
|
|
26
|
+
execute: async () => [],
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
selectFrom: (_table: string) => chain,
|
|
30
|
+
insertInto: (_table: string) => chain,
|
|
31
|
+
updateTable: (_table: string) => chain,
|
|
32
|
+
deleteFrom: (_table: string) => chain,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildEngine() {
|
|
37
|
+
const em = {
|
|
38
|
+
getKysely: () => buildDb(),
|
|
39
|
+
getMetadata: () => ({ find: () => undefined, getAll: () => [] }),
|
|
40
|
+
find: async () => [],
|
|
41
|
+
persist: () => undefined,
|
|
42
|
+
flush: async () => undefined,
|
|
43
|
+
}
|
|
44
|
+
return new DefaultDataEngine(em as never, {} as never)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('DataEngine EAV backcompat pins organizationId (#6034 review)', () => {
|
|
48
|
+
const previousFlag = process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
registerEntityIds({ example: { todo: ENTITY_ID } })
|
|
52
|
+
clearDataEngineTableExistsCache()
|
|
53
|
+
setRecordCustomFieldsMock.mockClear()
|
|
54
|
+
process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM = 'true'
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
registerEntityIds({})
|
|
59
|
+
if (previousFlag === undefined) delete process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM
|
|
60
|
+
else process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM = previousFlag
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// custom_entities_storage upserts on (entity_type, entity_id, organization_id), so the same
|
|
64
|
+
// recordId can be a live record in two organizations of one tenant — the recordId-is-globally-
|
|
65
|
+
// unique invariant `setRecordCustomFields` otherwise relies on does not hold here. Both backcompat
|
|
66
|
+
// call sites must pin the reconciling delete/lookup to their own organization.
|
|
67
|
+
test('createCustomEntityRecord passes pinOrganizationId: true to setRecordCustomFields', async () => {
|
|
68
|
+
const engine = buildEngine()
|
|
69
|
+
|
|
70
|
+
await engine.createCustomEntityRecord({
|
|
71
|
+
entityId: ENTITY_ID,
|
|
72
|
+
recordId: RECORD_ID,
|
|
73
|
+
organizationId: 'org-a',
|
|
74
|
+
tenantId: 'tenant-1',
|
|
75
|
+
values: { priority: 3 },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(setRecordCustomFieldsMock).toHaveBeenCalledTimes(1)
|
|
79
|
+
expect(setRecordCustomFieldsMock.mock.calls[0]?.[1]).toMatchObject({
|
|
80
|
+
organizationId: 'org-a',
|
|
81
|
+
pinOrganizationId: true,
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('updateCustomEntityRecord passes pinOrganizationId: true to setRecordCustomFields', async () => {
|
|
86
|
+
const engine = buildEngine()
|
|
87
|
+
|
|
88
|
+
await engine.updateCustomEntityRecord({
|
|
89
|
+
entityId: ENTITY_ID,
|
|
90
|
+
recordId: RECORD_ID,
|
|
91
|
+
organizationId: 'org-a',
|
|
92
|
+
tenantId: 'tenant-1',
|
|
93
|
+
values: { priority: 3 },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(setRecordCustomFieldsMock).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(setRecordCustomFieldsMock.mock.calls[0]?.[1]).toMatchObject({
|
|
98
|
+
organizationId: 'org-a',
|
|
99
|
+
pinOrganizationId: true,
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -98,6 +98,9 @@ export interface DataEngine {
|
|
|
98
98
|
tenantId?: string | null
|
|
99
99
|
values: Record<string, string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>>
|
|
100
100
|
notify?: boolean // default true -> emit '<module>.<entity>.updated'
|
|
101
|
+
// Forwarded to `setRecordCustomFields` — see its doc for when a caller must pass
|
|
102
|
+
// true (a recordId that is not unique across organizations for this entityId).
|
|
103
|
+
pinOrganizationId?: boolean
|
|
101
104
|
}): Promise<void>
|
|
102
105
|
|
|
103
106
|
// Storage for user-defined entities (doc-based)
|
|
@@ -287,6 +290,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
287
290
|
tenantId,
|
|
288
291
|
values: sanitizedValues,
|
|
289
292
|
encryptionService,
|
|
293
|
+
pinOrganizationId: opts.pinOrganizationId === true,
|
|
290
294
|
})
|
|
291
295
|
if (opts.notify !== false) {
|
|
292
296
|
let bus: EventBus | null = null
|
|
@@ -473,6 +477,10 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
473
477
|
tenantId: tenantId,
|
|
474
478
|
values: normalizeCustomFieldValues(sanitizedValues),
|
|
475
479
|
notify: opts.notify, // defaults to true
|
|
480
|
+
// custom_entities_storage upserts on (entity_type, entity_id, organization_id),
|
|
481
|
+
// so `id` is unique only within this organization, not across organizations —
|
|
482
|
+
// pin the reconciling delete/lookup to this organization (#6034 review).
|
|
483
|
+
pinOrganizationId: true,
|
|
476
484
|
})
|
|
477
485
|
}
|
|
478
486
|
|
|
@@ -544,6 +552,10 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
544
552
|
tenantId: tenantId,
|
|
545
553
|
values: normalizeCustomFieldValues(sanitizedValues),
|
|
546
554
|
notify: opts.notify, // defaults to true
|
|
555
|
+
// custom_entities_storage upserts on (entity_type, entity_id, organization_id),
|
|
556
|
+
// so `id` is unique only within this organization, not across organizations —
|
|
557
|
+
// pin the reconciling delete/lookup to this organization (#6034 review).
|
|
558
|
+
pinOrganizationId: true,
|
|
547
559
|
})
|
|
548
560
|
}
|
|
549
561
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
IMMUTABLE_UNACCENT_FUNCTION,
|
|
4
|
+
buildAccentInsensitiveContainsPatternSql,
|
|
5
|
+
buildAccentInsensitivePatternSql,
|
|
6
|
+
buildAccentInsensitiveSearchExpression,
|
|
7
|
+
buildImmutableUnaccentFunctionSql,
|
|
8
|
+
} from '../accentInsensitiveSearch'
|
|
9
|
+
|
|
10
|
+
describe('accent-insensitive search SQL', () => {
|
|
11
|
+
it('builds a NULL-safe, space-separated concatenation wrapped in the immutable unaccent function', () => {
|
|
12
|
+
expect(buildAccentInsensitiveSearchExpression(['title', 'sku'])).toBe(
|
|
13
|
+
`${IMMUTABLE_UNACCENT_FUNCTION}(coalesce("title", '') || ' ' || coalesce("sku", ''))`,
|
|
14
|
+
)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('wraps a single column without a separator', () => {
|
|
18
|
+
expect(buildAccentInsensitiveSearchExpression(['title'])).toBe(
|
|
19
|
+
`${IMMUTABLE_UNACCENT_FUNCTION}(coalesce("title", ''))`,
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects an empty column list rather than emitting an unparsable call', () => {
|
|
24
|
+
expect(() => buildAccentInsensitiveSearchExpression([])).toThrow()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('unaccents the bound pattern with the same function, so both sides of the comparison match', () => {
|
|
28
|
+
expect(buildAccentInsensitivePatternSql()).toBe(`${IMMUTABLE_UNACCENT_FUNCTION}(?)`)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// unaccent folds fullwidth % _ \ into ASCII LIKE metacharacters, so the raw
|
|
32
|
+
// term must be folded first and escaped afterwards (#6465).
|
|
33
|
+
it('escapes LIKE metacharacters after unaccent folds the raw term, backslash first', () => {
|
|
34
|
+
const sql = buildAccentInsensitiveContainsPatternSql()
|
|
35
|
+
expect(sql).toBe(
|
|
36
|
+
`'%' || replace(replace(replace(${IMMUTABLE_UNACCENT_FUNCTION}(?), chr(92), chr(92) || chr(92)), '%', chr(92) || '%'), '_', chr(92) || '_') || '%'`,
|
|
37
|
+
)
|
|
38
|
+
expect(sql.split('?')).toHaveLength(2)
|
|
39
|
+
expect(sql).not.toContain('\\')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// The wrapper may only be declared IMMUTABLE because the dictionary is pinned:
|
|
43
|
+
// the single-argument unaccent() resolves it through search_path and is STABLE,
|
|
44
|
+
// which PostgreSQL refuses inside an index expression.
|
|
45
|
+
it('pins the text-search dictionary instead of relying on search_path', () => {
|
|
46
|
+
const sql = buildImmutableUnaccentFunctionSql()
|
|
47
|
+
expect(sql).toContain(`create or replace function ${IMMUTABLE_UNACCENT_FUNCTION}(text)`)
|
|
48
|
+
expect(sql).toContain(`public.unaccent('public.unaccent'::regdictionary, $1)`)
|
|
49
|
+
expect(sql).toContain('immutable')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL text for accent-insensitive `ILIKE` matching in PostgreSQL.
|
|
3
|
+
*
|
|
4
|
+
* A query predicate and the expression index backing it must be spelled
|
|
5
|
+
* identically or the planner silently ignores the index, so both sides build
|
|
6
|
+
* their SQL from the helpers here instead of repeating the literal. These are
|
|
7
|
+
* pure string builders — they assume nothing about which migration installed
|
|
8
|
+
* the function, only that both sides agree on its name.
|
|
9
|
+
*
|
|
10
|
+
* The function itself is created by a migration (currently
|
|
11
|
+
* `packages/core/src/modules/catalog/migrations/`); promote that migration to a
|
|
12
|
+
* platform-level one when a second module needs the same matching.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Schema-qualified name of the IMMUTABLE `unaccent()` wrapper the expressions
|
|
17
|
+
* below call.
|
|
18
|
+
*
|
|
19
|
+
* PostgreSQL's built-in `unaccent()` is STABLE, because the single-argument form
|
|
20
|
+
* resolves its text-search dictionary through `search_path`, and a STABLE
|
|
21
|
+
* function cannot appear in an index expression. The wrapper pins the dictionary
|
|
22
|
+
* with `'public.unaccent'::regdictionary` so it is genuinely immutable rather
|
|
23
|
+
* than merely declared so — but the wrapper's own *call site* is still resolved
|
|
24
|
+
* through `search_path` unless it is schema-qualified too, so both the index
|
|
25
|
+
* expression and the runtime query predicate reference `public.` explicitly
|
|
26
|
+
* rather than relying on the caller's `search_path` containing it.
|
|
27
|
+
*/
|
|
28
|
+
export const IMMUTABLE_UNACCENT_FUNCTION = 'public.om_immutable_unaccent'
|
|
29
|
+
|
|
30
|
+
/** DDL creating the wrapper. Kept here so the migration and the query agree. */
|
|
31
|
+
export const buildImmutableUnaccentFunctionSql = (): string =>
|
|
32
|
+
`create or replace function ${IMMUTABLE_UNACCENT_FUNCTION}(text)
|
|
33
|
+
returns text as $$
|
|
34
|
+
select public.unaccent('public.unaccent'::regdictionary, $1)
|
|
35
|
+
$$ language sql immutable parallel safe strict;`
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `unaccent`ed concatenation of `columns`, NULL-safe and space-separated.
|
|
39
|
+
*
|
|
40
|
+
* Column names are quoted but not table-qualified: the caller's query must
|
|
41
|
+
* reference a single table, which is also what the index expression sees.
|
|
42
|
+
*/
|
|
43
|
+
export const buildAccentInsensitiveSearchExpression = (columns: readonly string[]): string => {
|
|
44
|
+
if (!columns.length) throw new Error('[internal] buildAccentInsensitiveSearchExpression requires at least one column')
|
|
45
|
+
const concatenated = columns.map((column) => `coalesce("${column}", '')`).join(` || ' ' || `)
|
|
46
|
+
return `${IMMUTABLE_UNACCENT_FUNCTION}(${concatenated})`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Right-hand side of the comparison: the bound pattern, unaccented the same way.
|
|
51
|
+
*
|
|
52
|
+
* @deprecated `unaccent` folds look-alikes such as fullwidth `% _ \` into the
|
|
53
|
+
* ASCII LIKE metacharacters, so a pattern escaped before this call comes out of
|
|
54
|
+
* it with live wildcards (#6465). Use `buildAccentInsensitiveContainsPatternSql`,
|
|
55
|
+
* which binds the raw term and escapes it after folding.
|
|
56
|
+
*/
|
|
57
|
+
export const buildAccentInsensitivePatternSql = (): string => `${IMMUTABLE_UNACCENT_FUNCTION}(?)`
|
|
58
|
+
|
|
59
|
+
const escapeLikeMetacharactersSql = (expression: string): string =>
|
|
60
|
+
`replace(replace(replace(${expression}, chr(92), chr(92) || chr(92)), '%', chr(92) || '%'), '_', chr(92) || '_')`
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Right-hand side of a "contains" comparison for a RAW (unescaped) search term
|
|
64
|
+
* bound as the single `?`: the term is unaccented first and only then
|
|
65
|
+
* LIKE-escaped, so characters that `unaccent` folds into `%`, `_` or `\` are
|
|
66
|
+
* matched literally. `chr(92)` spells the backslash independently of
|
|
67
|
+
* `standard_conforming_strings`.
|
|
68
|
+
*/
|
|
69
|
+
export const buildAccentInsensitiveContainsPatternSql = (): string =>
|
|
70
|
+
`'%' || ${escapeLikeMetacharactersSql(`${IMMUTABLE_UNACCENT_FUNCTION}(?)`)} || '%'`
|
|
@@ -107,6 +107,14 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
|
|
|
107
107
|
jest.restoreAllMocks()
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
+
function makePolicyEm() {
|
|
111
|
+
return {
|
|
112
|
+
execute: jest.fn(async () => [
|
|
113
|
+
{ entity_id: entityId, tenant_id: null, organization_id: null, fields_json: [{ field: 'secret' }] },
|
|
114
|
+
]),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
110
118
|
function makeCreatingKms() {
|
|
111
119
|
const created: string[] = []
|
|
112
120
|
const kms: KmsService = {
|
|
@@ -136,10 +144,8 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
|
|
|
136
144
|
|
|
137
145
|
it('dedupes concurrent first-time DEK creation so no row is orphaned', async () => {
|
|
138
146
|
const { kms, created } = makeCreatingKms()
|
|
139
|
-
const service = new TenantDataEncryptionService(
|
|
147
|
+
const service = new TenantDataEncryptionService(makePolicyEm() as never, { kms })
|
|
140
148
|
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
141
|
-
;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
|
|
142
|
-
jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
|
|
143
149
|
const tenantId = uniqueTenant('race')
|
|
144
150
|
|
|
145
151
|
const rows = await Promise.all(
|
|
@@ -176,10 +182,8 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
|
|
|
176
182
|
|
|
177
183
|
it('provisions no DEK when the caller opts out, so a preview leaves KMS untouched', async () => {
|
|
178
184
|
const { kms, created } = makeCreatingKms()
|
|
179
|
-
const service = new TenantDataEncryptionService(
|
|
185
|
+
const service = new TenantDataEncryptionService(makePolicyEm() as never, { kms })
|
|
180
186
|
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
181
|
-
;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
|
|
182
|
-
jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
|
|
183
187
|
const tenantId = uniqueTenant('no-create')
|
|
184
188
|
|
|
185
189
|
const row = await service.encryptEntityPayload(
|
|
@@ -198,10 +202,8 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
|
|
|
198
202
|
|
|
199
203
|
it('still provisions on the default path so existing write callers are unaffected', async () => {
|
|
200
204
|
const { kms, created } = makeCreatingKms()
|
|
201
|
-
const service = new TenantDataEncryptionService(
|
|
205
|
+
const service = new TenantDataEncryptionService(makePolicyEm() as never, { kms })
|
|
202
206
|
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
203
|
-
;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
|
|
204
|
-
jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
|
|
205
207
|
const tenantId = uniqueTenant('default-create')
|
|
206
208
|
|
|
207
209
|
const row = await service.encryptEntityPayload(entityId, { secret: 'value' }, tenantId)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { TenantDataEncryptionService } from '../../tenantDataEncryptionService'
|
|
3
|
+
|
|
4
|
+
type PolicyState = {
|
|
5
|
+
exact: Array<{ field: string }>
|
|
6
|
+
aggregate: Array<{ field: string }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type WorkerCommand = {
|
|
10
|
+
id: number
|
|
11
|
+
action: 'readExact' | 'readAggregate' | 'invalidate'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const entityId = 'test:process_policy'
|
|
15
|
+
const tenantId = 'tenant-process-policy'
|
|
16
|
+
const organizationId = 'org-process-policy'
|
|
17
|
+
const statePath = process.env.ENCRYPTION_POLICY_STATE_PATH
|
|
18
|
+
const cacheFailurePath = process.env.ENCRYPTION_POLICY_CACHE_FAILURE_PATH
|
|
19
|
+
const exactReadBlockPath = process.env.ENCRYPTION_POLICY_EXACT_BLOCK_PATH
|
|
20
|
+
|
|
21
|
+
if (!statePath || !cacheFailurePath || !exactReadBlockPath) {
|
|
22
|
+
throw new Error('[internal] Process policy fixture paths are required')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readPolicyState(): Promise<PolicyState> {
|
|
26
|
+
return JSON.parse(await readFile(statePath, 'utf8')) as PolicyState
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function cacheIsUnavailable(): Promise<boolean> {
|
|
30
|
+
return (await readFile(cacheFailurePath, 'utf8')).trim() === 'unavailable'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let hasBlockedExactRead = false
|
|
34
|
+
|
|
35
|
+
// One statement resolves a scope: `[entity, tenant]` is the tenant-wide read that also returns
|
|
36
|
+
// every organization-scoped row for the all-organizations aggregate, `[entity, tenant, org]` the
|
|
37
|
+
// exact read.
|
|
38
|
+
const execute = async (_sql: string, params: readonly unknown[] = []) => {
|
|
39
|
+
const state = await readPolicyState()
|
|
40
|
+
if (params.length === 2) {
|
|
41
|
+
return [{ entity_id: entityId, tenant_id: tenantId, organization_id: 'org-aggregate', fields_json: state.aggregate }]
|
|
42
|
+
}
|
|
43
|
+
if (params[2] === organizationId) {
|
|
44
|
+
if (!hasBlockedExactRead && (await readFile(exactReadBlockPath, 'utf8')).trim() === 'blocked') {
|
|
45
|
+
hasBlockedExactRead = true
|
|
46
|
+
process.send?.({ exactReadBlocked: true })
|
|
47
|
+
while ((await readFile(exactReadBlockPath, 'utf8')).trim() === 'blocked') {
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [{ entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: state.exact }]
|
|
52
|
+
}
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cache = {
|
|
57
|
+
get: async () => ({ entityId, fields: [{ field: 'stale_shared_cache' }] }),
|
|
58
|
+
set: async () => {
|
|
59
|
+
if (await cacheIsUnavailable()) throw new Error('cache publication failed')
|
|
60
|
+
},
|
|
61
|
+
delete: async () => {
|
|
62
|
+
if (await cacheIsUnavailable()) throw new Error('cache invalidation failed')
|
|
63
|
+
return true
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const service = new TenantDataEncryptionService(
|
|
68
|
+
{ execute, getConnection: () => ({ execute }) } as never,
|
|
69
|
+
{
|
|
70
|
+
cache: cache as never,
|
|
71
|
+
kms: {
|
|
72
|
+
getTenantDek: async () => null,
|
|
73
|
+
createTenantDek: async () => null,
|
|
74
|
+
isHealthy: () => true,
|
|
75
|
+
},
|
|
76
|
+
} as never,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
process.on('message', async (command: WorkerCommand) => {
|
|
80
|
+
try {
|
|
81
|
+
const value = command.action === 'readExact'
|
|
82
|
+
? await service.getEncryptedFieldNames(entityId, tenantId, organizationId)
|
|
83
|
+
: command.action === 'readAggregate'
|
|
84
|
+
? await service.getEncryptedFieldNames(entityId, tenantId, null)
|
|
85
|
+
: await service.invalidateMap(entityId, tenantId, organizationId)
|
|
86
|
+
process.send?.({ id: command.id, ok: true, value })
|
|
87
|
+
} catch (error) {
|
|
88
|
+
process.send?.({
|
|
89
|
+
id: command.id,
|
|
90
|
+
ok: false,
|
|
91
|
+
error: error instanceof Error ? error.message : String(error),
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
process.send?.({ ready: true })
|