@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
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { buildDerivedKeyFallbackBannerLines, createKmsService, HashicorpVaultKmsService } from '../kms'
|
|
3
3
|
|
|
4
|
+
jest.mock('../../logger', () => {
|
|
5
|
+
const warn = jest.fn()
|
|
6
|
+
const logger: Record<string, unknown> = {
|
|
7
|
+
debug: jest.fn(),
|
|
8
|
+
info: jest.fn(),
|
|
9
|
+
warn,
|
|
10
|
+
error: jest.fn(),
|
|
11
|
+
}
|
|
12
|
+
logger.child = () => logger
|
|
13
|
+
return { createLogger: () => logger, __warn: warn }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const loggerModule = jest.requireMock('../../logger') as { __warn: jest.Mock }
|
|
4
17
|
const originalEnv = { ...process.env }
|
|
5
18
|
|
|
6
19
|
describe('kms timeout handling', () => {
|
|
@@ -74,8 +87,9 @@ describe('kms timeout handling', () => {
|
|
|
74
87
|
expect(dek).toBeNull()
|
|
75
88
|
})
|
|
76
89
|
|
|
77
|
-
it('never prints the explicit fallback secret
|
|
90
|
+
it('never prints the explicit fallback secret or a deterministic verifier in the banner', () => {
|
|
78
91
|
const secret = 'super-secret-tenant-encryption-key'
|
|
92
|
+
const fingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
|
|
79
93
|
for (const nodeEnv of ['development', 'staging', 'preview', 'PRODUCTION', 'production', undefined]) {
|
|
80
94
|
if (nodeEnv === undefined) delete process.env.NODE_ENV
|
|
81
95
|
else process.env.NODE_ENV = nodeEnv
|
|
@@ -88,12 +102,40 @@ describe('kms timeout handling', () => {
|
|
|
88
102
|
const rendered = lines.join('\n')
|
|
89
103
|
|
|
90
104
|
expect(rendered).not.toContain(secret)
|
|
105
|
+
expect(rendered).not.toContain(fingerprint)
|
|
106
|
+
expect(rendered).not.toMatch(/fingerprint|sha-?256/i)
|
|
91
107
|
expect(rendered).toContain('Source: TENANT_DATA_ENCRYPTION_FALLBACK_KEY')
|
|
92
|
-
const expectedFingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
|
|
93
|
-
expect(rendered).toContain(`Secret fingerprint (sha256, truncated): ${expectedFingerprint}`)
|
|
94
108
|
}
|
|
95
109
|
})
|
|
96
110
|
|
|
111
|
+
it('logs fallback activation without the secret or a deterministic verifier', () => {
|
|
112
|
+
const secret = 'structured-log-fallback-secret'
|
|
113
|
+
const fingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
|
|
114
|
+
process.env.NODE_ENV = 'production'
|
|
115
|
+
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
116
|
+
process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY = secret
|
|
117
|
+
delete process.env.TENANT_DATA_ENCRYPTION_KEY
|
|
118
|
+
delete process.env.VAULT_ADDR
|
|
119
|
+
delete process.env.VAULT_TOKEN
|
|
120
|
+
loggerModule.__warn.mockClear()
|
|
121
|
+
const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
122
|
+
|
|
123
|
+
createKmsService()
|
|
124
|
+
|
|
125
|
+
const bannerOutput = stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join('')
|
|
126
|
+
const structuredOutput = JSON.stringify(loggerModule.__warn.mock.calls)
|
|
127
|
+
for (const output of [bannerOutput, structuredOutput]) {
|
|
128
|
+
expect(output).not.toContain(secret)
|
|
129
|
+
expect(output).not.toContain(fingerprint)
|
|
130
|
+
expect(output).not.toMatch(/secretFingerprint|sha-?256/i)
|
|
131
|
+
}
|
|
132
|
+
expect(bannerOutput).toContain('Source: TENANT_DATA_ENCRYPTION_FALLBACK_KEY')
|
|
133
|
+
expect(loggerModule.__warn).toHaveBeenCalledWith(
|
|
134
|
+
'Using derived tenant encryption keys (Vault unavailable / no DEK)',
|
|
135
|
+
{ fallbackSource: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
97
139
|
it('does not echo the dev default secret verbatim in the banner either', () => {
|
|
98
140
|
const lines = buildDerivedKeyFallbackBannerLines({
|
|
99
141
|
secret: 'om-dev-tenant-encryption',
|
|
@@ -99,6 +99,49 @@ describe('warnOnEncryptedLikeFilter', () => {
|
|
|
99
99
|
expect(loggerModule.__warn.mock.calls[0][1].hint).toContain('findEntityIdsBySearchTokens')
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
+
// A predicate built with raw() is keyed by a symbol, which the walker cannot
|
|
103
|
+
// see, so the caller names the matched properties instead (#6074 / #5051).
|
|
104
|
+
it('warns for properties supplied as likeFields without a readable where clause', async () => {
|
|
105
|
+
await warnOnEncryptedLikeFilter({
|
|
106
|
+
em: makeEm() as any,
|
|
107
|
+
entityName: 'MailMessage',
|
|
108
|
+
likeFields: ['subject', 'body'],
|
|
109
|
+
tenantId: 'tenant-1',
|
|
110
|
+
encryptionService: makeService(['subject']) as any,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
114
|
+
expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({
|
|
115
|
+
entity: 'mail:mail_message',
|
|
116
|
+
field: 'subject',
|
|
117
|
+
reason: 'raw-orm-filter',
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('reports a property once when it appears in both the where clause and likeFields', async () => {
|
|
122
|
+
await warnOnEncryptedLikeFilter({
|
|
123
|
+
em: makeEm() as any,
|
|
124
|
+
entityName: 'MailMessage',
|
|
125
|
+
where: { subject: { $ilike: '%invoice%' } },
|
|
126
|
+
likeFields: ['subject'],
|
|
127
|
+
tenantId: 'tenant-1',
|
|
128
|
+
encryptionService: makeService(['subject']) as any,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('stays quiet for likeFields outside the encryption map', async () => {
|
|
135
|
+
await warnOnEncryptedLikeFilter({
|
|
136
|
+
em: makeEm() as any,
|
|
137
|
+
entityName: 'MailMessage',
|
|
138
|
+
likeFields: ['subject'],
|
|
139
|
+
tenantId: 'tenant-1',
|
|
140
|
+
encryptionService: makeService(['body']) as any,
|
|
141
|
+
})
|
|
142
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
143
|
+
})
|
|
144
|
+
|
|
102
145
|
it('stays quiet for properties outside the encryption map', async () => {
|
|
103
146
|
await warnOnEncryptedLikeFilter({
|
|
104
147
|
em: makeEm() as any,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { buildEncryptionMapBackfillSql } from '../migration-backfill'
|
|
2
|
+
|
|
3
|
+
function normalize(sql: string): string {
|
|
4
|
+
return sql.replace(/\s+/g, ' ').trim()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
describe('buildEncryptionMapBackfillSql', () => {
|
|
8
|
+
test('emits a single table-guarded block that inserts missing maps for active scopes', () => {
|
|
9
|
+
const sql = normalize(buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }] }))
|
|
10
|
+
|
|
11
|
+
expect(sql.startsWith('do $$ begin if to_regclass(\'encryption_maps\') is null then return; end if;')).toBe(true)
|
|
12
|
+
expect(sql.endsWith('end $$;')).toBe(true)
|
|
13
|
+
expect(sql).toContain(
|
|
14
|
+
`select gen_random_uuid(), 'demo:thing', src."tenant_id", src."organization_id", '[{"field":"secret"}]'::jsonb, true, now(), now()`,
|
|
15
|
+
)
|
|
16
|
+
expect(sql).toContain('where "is_active" = true and "deleted_at" is null')
|
|
17
|
+
expect(sql).toContain(`where existing."entity_id" = 'demo:thing'`)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('appends each missing field rule to existing maps without replacing other rules', () => {
|
|
21
|
+
const sql = normalize(
|
|
22
|
+
buildEncryptionMapBackfillSql({
|
|
23
|
+
entityId: 'demo:thing',
|
|
24
|
+
fields: [{ field: 'secret' }, { field: 'email', hashField: 'email_hash' }],
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(sql).toContain(`'[{"field":"secret"},{"field":"email","hashField":"email_hash"}]'::jsonb`)
|
|
29
|
+
expect(sql).toContain(`coalesce("fields_json", '[]'::jsonb) || '[{"field":"secret"}]'::jsonb`)
|
|
30
|
+
expect(sql).toContain(`coalesce("fields_json", '[]'::jsonb) || '[{"field":"email","hashField":"email_hash"}]'::jsonb`)
|
|
31
|
+
expect(sql).toContain(`where rule->>'field' = 'secret'`)
|
|
32
|
+
expect(sql).toContain(`where rule->>'field' = 'email'`)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('deduplicates repeated field rules', () => {
|
|
36
|
+
const sql = buildEncryptionMapBackfillSql({ entityId: 'demo:thing', fields: [{ field: 'secret' }, { field: 'secret' }] })
|
|
37
|
+
|
|
38
|
+
expect(sql.match(/update "encryption_maps"/g)).toHaveLength(1)
|
|
39
|
+
expect(sql).toContain(`'[{"field":"secret"}]'::jsonb, true`)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test.each([
|
|
43
|
+
['entity id with a quote', { entityId: "demo:thing'; drop table x; --", fields: [{ field: 'secret' }] }],
|
|
44
|
+
['entity id without module prefix', { entityId: 'thing', fields: [{ field: 'secret' }] }],
|
|
45
|
+
['field with a quote', { entityId: 'demo:thing', fields: [{ field: "secret'" }] }],
|
|
46
|
+
['hash field with a quote', { entityId: 'demo:thing', fields: [{ field: 'email', hashField: "x'" }] }],
|
|
47
|
+
['no fields', { entityId: 'demo:thing', fields: [] }],
|
|
48
|
+
])('rejects %s', (_label, map) => {
|
|
49
|
+
expect(() => buildEncryptionMapBackfillSql(map)).toThrow('[internal]')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('rejects system-scoped maps, which never live in encryption_maps', () => {
|
|
53
|
+
expect(() =>
|
|
54
|
+
buildEncryptionMapBackfillSql({ entityId: 'demo:thing', keyScope: 'system', fields: [{ field: 'secret' }] }),
|
|
55
|
+
).toThrow('system-scoped')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { fork, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
type WorkerResponse = {
|
|
7
|
+
id?: number
|
|
8
|
+
ready?: boolean
|
|
9
|
+
exactReadBlocked?: boolean
|
|
10
|
+
ok?: boolean
|
|
11
|
+
value?: unknown
|
|
12
|
+
error?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function waitForMessage(worker: ChildProcess, predicate: (message: WorkerResponse) => boolean): Promise<WorkerResponse> {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const onMessage = (message: WorkerResponse) => {
|
|
18
|
+
if (!predicate(message)) return
|
|
19
|
+
cleanup()
|
|
20
|
+
resolve(message)
|
|
21
|
+
}
|
|
22
|
+
const onExit = (code: number | null) => {
|
|
23
|
+
cleanup()
|
|
24
|
+
reject(new Error(`[internal] Policy worker exited with code ${String(code)}`))
|
|
25
|
+
}
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
worker.off('message', onMessage)
|
|
28
|
+
worker.off('exit', onExit)
|
|
29
|
+
}
|
|
30
|
+
worker.on('message', onMessage)
|
|
31
|
+
worker.on('exit', onExit)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function startWorker(
|
|
36
|
+
statePath: string,
|
|
37
|
+
cacheFailurePath: string,
|
|
38
|
+
exactReadBlockPath: string,
|
|
39
|
+
): Promise<ChildProcess> {
|
|
40
|
+
const worker = fork(join(__dirname, 'fixtures/policy-process-worker.ts'), [], {
|
|
41
|
+
execArgv: ['--import', require.resolve('tsx')],
|
|
42
|
+
env: {
|
|
43
|
+
...process.env,
|
|
44
|
+
TENANT_DATA_ENCRYPTION: 'yes',
|
|
45
|
+
ENCRYPTION_POLICY_STATE_PATH: statePath,
|
|
46
|
+
ENCRYPTION_POLICY_CACHE_FAILURE_PATH: cacheFailurePath,
|
|
47
|
+
ENCRYPTION_POLICY_EXACT_BLOCK_PATH: exactReadBlockPath,
|
|
48
|
+
},
|
|
49
|
+
stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
|
|
50
|
+
})
|
|
51
|
+
await waitForMessage(worker, (message) => message.ready === true)
|
|
52
|
+
return worker
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let commandId = 0
|
|
56
|
+
|
|
57
|
+
async function runCommand(worker: ChildProcess, action: 'readExact' | 'readAggregate' | 'invalidate') {
|
|
58
|
+
const id = ++commandId
|
|
59
|
+
const response = waitForMessage(worker, (message) => message.id === id)
|
|
60
|
+
worker.send({ id, action })
|
|
61
|
+
return response
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('encryption policy coherence across processes', () => {
|
|
65
|
+
it('re-reads exact and aggregate policy after a remote commit while the shared cache is unavailable', async () => {
|
|
66
|
+
const directory = await mkdtemp(join(tmpdir(), 'om-encryption-policy-'))
|
|
67
|
+
const statePath = join(directory, 'policy.json')
|
|
68
|
+
const cacheFailurePath = join(directory, 'cache-status.txt')
|
|
69
|
+
const exactReadBlockPath = join(directory, 'exact-read-block.txt')
|
|
70
|
+
const workers: ChildProcess[] = []
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(statePath, JSON.stringify({
|
|
73
|
+
exact: [{ field: 'stale_exact' }],
|
|
74
|
+
aggregate: [{ field: 'stale_aggregate' }],
|
|
75
|
+
}))
|
|
76
|
+
await writeFile(cacheFailurePath, 'available')
|
|
77
|
+
await writeFile(exactReadBlockPath, 'released')
|
|
78
|
+
const reader = await startWorker(statePath, cacheFailurePath, exactReadBlockPath)
|
|
79
|
+
const invalidator = await startWorker(statePath, cacheFailurePath, exactReadBlockPath)
|
|
80
|
+
workers.push(reader, invalidator)
|
|
81
|
+
|
|
82
|
+
await expect(runCommand(reader, 'readExact')).resolves.toMatchObject({
|
|
83
|
+
ok: true,
|
|
84
|
+
value: ['stale_exact'],
|
|
85
|
+
})
|
|
86
|
+
await expect(runCommand(reader, 'readAggregate')).resolves.toMatchObject({
|
|
87
|
+
ok: true,
|
|
88
|
+
value: ['stale_aggregate'],
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
await writeFile(exactReadBlockPath, 'blocked')
|
|
92
|
+
const exactReadBlocked = waitForMessage(reader, (message) => message.exactReadBlocked === true)
|
|
93
|
+
const blockedPreCommitRead = runCommand(reader, 'readExact')
|
|
94
|
+
await exactReadBlocked
|
|
95
|
+
|
|
96
|
+
await writeFile(statePath, JSON.stringify({
|
|
97
|
+
exact: [{ field: 'fresh_exact' }],
|
|
98
|
+
aggregate: [{ field: 'fresh_aggregate' }],
|
|
99
|
+
}))
|
|
100
|
+
await writeFile(cacheFailurePath, 'unavailable')
|
|
101
|
+
await expect(runCommand(invalidator, 'invalidate')).resolves.toMatchObject({ ok: true })
|
|
102
|
+
|
|
103
|
+
await expect(runCommand(reader, 'readExact')).resolves.toMatchObject({
|
|
104
|
+
ok: true,
|
|
105
|
+
value: ['fresh_exact'],
|
|
106
|
+
})
|
|
107
|
+
await expect(runCommand(reader, 'readAggregate')).resolves.toMatchObject({
|
|
108
|
+
ok: true,
|
|
109
|
+
value: ['fresh_aggregate'],
|
|
110
|
+
})
|
|
111
|
+
await writeFile(exactReadBlockPath, 'released')
|
|
112
|
+
await expect(blockedPreCommitRead).resolves.toMatchObject({
|
|
113
|
+
ok: true,
|
|
114
|
+
value: ['stale_exact'],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
await writeFile(cacheFailurePath, 'available')
|
|
118
|
+
await expect(runCommand(invalidator, 'invalidate')).resolves.toMatchObject({ ok: true })
|
|
119
|
+
await expect(runCommand(reader, 'readExact')).resolves.toMatchObject({
|
|
120
|
+
ok: true,
|
|
121
|
+
value: ['fresh_exact'],
|
|
122
|
+
})
|
|
123
|
+
} finally {
|
|
124
|
+
for (const worker of workers) worker.kill()
|
|
125
|
+
await rm(directory, { recursive: true, force: true })
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ReferenceKind } from '@mikro-orm/core'
|
|
1
|
+
import { DriverException, ReferenceKind } from '@mikro-orm/core'
|
|
2
2
|
import { TenantEncryptionSubscriber } from '../subscriber'
|
|
3
3
|
import { registerEntityIds } from '../entityIds'
|
|
4
4
|
import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
|
|
@@ -121,3 +121,68 @@ describe('TenantEncryptionSubscriber deep-decrypt of loaded collection relations
|
|
|
121
121
|
expect(decryptedEntityIds).toContain('test:child')
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
describe('TenantEncryptionSubscriber nested decrypt failures inside a transaction', () => {
|
|
126
|
+
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
delete process.env.TENANT_DATA_ENCRYPTION
|
|
130
|
+
registerEntityIds({ test: { parent: 'test:parent', child: 'test:child' } })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
135
|
+
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
function makeFailingChildService(error: Error): TenantDataEncryptionService {
|
|
139
|
+
return {
|
|
140
|
+
isEnabled: () => true,
|
|
141
|
+
async decryptEntityPayload(entityId: string) {
|
|
142
|
+
if (entityId === 'test:child') throw error
|
|
143
|
+
return {}
|
|
144
|
+
},
|
|
145
|
+
} as unknown as TenantDataEncryptionService
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeGraph() {
|
|
149
|
+
const child = { secret: 'enc:zeta', tenantId: 't1', __meta: CHILD_META }
|
|
150
|
+
const meta = parentMeta(ReferenceKind.MANY_TO_ONE, 'child')
|
|
151
|
+
return { meta, parent: { tenantId: 't1', child: makeReference(child), __meta: meta } }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function makeTransactionalEm(inTransaction: boolean) {
|
|
155
|
+
return { ...makeEm(), isInTransaction: () => inTransaction }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const abortedStatement = () => new DriverException(
|
|
159
|
+
Object.assign(new Error('current transaction is aborted'), { code: '25P02' }),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
it('rethrows a database error so the caller sees its aborted transaction', async () => {
|
|
163
|
+
const { meta, parent } = makeGraph()
|
|
164
|
+
const subscriber = new TenantEncryptionSubscriber(makeFailingChildService(abortedStatement()))
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
subscriber.decryptEntityGraph(parent, meta, makeTransactionalEm(true), { syncOriginal: true }),
|
|
168
|
+
).rejects.toBeInstanceOf(DriverException)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('keeps swallowing a database error outside a transaction', async () => {
|
|
172
|
+
const { meta, parent } = makeGraph()
|
|
173
|
+
const subscriber = new TenantEncryptionSubscriber(makeFailingChildService(abortedStatement()))
|
|
174
|
+
|
|
175
|
+
await expect(
|
|
176
|
+
subscriber.decryptEntityGraph(parent, meta, makeTransactionalEm(false), { syncOriginal: true }),
|
|
177
|
+
).resolves.toBeUndefined()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('keeps swallowing a non-database decrypt error inside a transaction', async () => {
|
|
181
|
+
const { meta, parent } = makeGraph()
|
|
182
|
+
const subscriber = new TenantEncryptionSubscriber(makeFailingChildService(new Error('malformed payload')))
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
subscriber.decryptEntityGraph(parent, meta, makeTransactionalEm(true), { syncOriginal: true }),
|
|
186
|
+
).resolves.toBeUndefined()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -192,11 +192,14 @@ describe('tenant-less entity encryption delegation', () => {
|
|
|
192
192
|
changeSet,
|
|
193
193
|
} as never)
|
|
194
194
|
|
|
195
|
+
// The flushing EntityManager is handed through so the policy lookup runs on
|
|
196
|
+
// this transaction's connection instead of acquiring a second pooled one.
|
|
195
197
|
expect(encryptEntityPayload).toHaveBeenCalledWith(
|
|
196
198
|
'onboarding:onboarding_request',
|
|
197
199
|
expect.any(Object),
|
|
198
200
|
null,
|
|
199
201
|
null,
|
|
202
|
+
{ em: {} },
|
|
200
203
|
)
|
|
201
204
|
expect(entity.secret).toBe('encrypted')
|
|
202
205
|
expect(changeSet.payload.secret).toBe('encrypted')
|