@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
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
TenantDataEncryptionService,
|
|
11
11
|
parseDecryptedFieldValue,
|
|
12
12
|
} from '../tenantDataEncryptionService'
|
|
13
|
+
import { forgetEncryptionPolicyMemo } from '../policyMemo'
|
|
13
14
|
|
|
14
15
|
const fixedKey = Buffer.alloc(32, 1).toString('base64')
|
|
15
16
|
|
|
@@ -306,6 +307,7 @@ describe('TenantDataEncryptionService system-scoped maps', () => {
|
|
|
306
307
|
))
|
|
307
308
|
const service = new TenantDataEncryptionService(
|
|
308
309
|
{
|
|
310
|
+
execute: jest.fn(async () => []),
|
|
309
311
|
getConnection: () => ({ execute: jest.fn(async () => []) }),
|
|
310
312
|
} as never,
|
|
311
313
|
{
|
|
@@ -342,47 +344,373 @@ describe('TenantDataEncryptionService system-scoped maps', () => {
|
|
|
342
344
|
})
|
|
343
345
|
})
|
|
344
346
|
|
|
347
|
+
type PolicyRow = {
|
|
348
|
+
entity_id: string
|
|
349
|
+
tenant_id: string | null
|
|
350
|
+
organization_id: string | null
|
|
351
|
+
fields_json: Array<{ field: string; hashField?: string | null }>
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Evaluates the scope predicate of the single policy statement against an in-memory table:
|
|
355
|
+
// `[entityId, tenantId]` selects every row of the tenant plus the global row (tenant-wide scope,
|
|
356
|
+
// including the all-organizations aggregate); `[entityId, tenantId, organizationId]` selects the
|
|
357
|
+
// exact, tenant-wide and global rows only.
|
|
358
|
+
function selectPolicyRows(table: readonly PolicyRow[], params: readonly unknown[]): PolicyRow[] {
|
|
359
|
+
const [entityId, tenantId, organizationId] = params
|
|
360
|
+
return table.filter((row) => {
|
|
361
|
+
if (row.entity_id !== entityId) return false
|
|
362
|
+
if (row.tenant_id === null && row.organization_id === null) return true
|
|
363
|
+
if (row.tenant_id !== (tenantId ?? null)) return false
|
|
364
|
+
if (params.length === 2) return true
|
|
365
|
+
return row.organization_id === null || row.organization_id === organizationId
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function makePolicyEm(readTable: () => readonly PolicyRow[]) {
|
|
370
|
+
const execute = jest.fn(async (_sql: string, params: readonly unknown[] = []) => selectPolicyRows(readTable(), params))
|
|
371
|
+
return { em: { execute }, execute }
|
|
372
|
+
}
|
|
373
|
+
|
|
345
374
|
describe('TenantDataEncryptionService.getEncryptedFieldNames', () => {
|
|
346
375
|
it('returns active encryption-map field names for query planning', async () => {
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
376
|
+
const entityId = 'customers:customer_entity'
|
|
377
|
+
const { em } = makePolicyEm(() => [{
|
|
378
|
+
entity_id: entityId,
|
|
379
|
+
tenant_id: 't1',
|
|
380
|
+
organization_id: 'org1',
|
|
381
|
+
fields_json: [
|
|
353
382
|
{ field: 'display_name' },
|
|
354
383
|
{ field: 'primary_email' },
|
|
355
384
|
{ field: '' },
|
|
356
|
-
{ field: null },
|
|
385
|
+
{ field: null as unknown as string },
|
|
357
386
|
],
|
|
358
|
-
})
|
|
387
|
+
}])
|
|
388
|
+
const service = new TenantDataEncryptionService(em as never)
|
|
389
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
359
390
|
|
|
360
391
|
await expect(
|
|
361
|
-
service.getEncryptedFieldNames(
|
|
392
|
+
service.getEncryptedFieldNames(entityId, 't1', 'org1'),
|
|
362
393
|
).resolves.toEqual(['display_name', 'primary_email'])
|
|
363
394
|
})
|
|
364
395
|
|
|
365
|
-
it('returns org-scoped field union for all-organization query planning', async () => {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const service = new TenantDataEncryptionService({
|
|
374
|
-
getConnection: () => ({ execute }),
|
|
375
|
-
} as never)
|
|
396
|
+
it('returns org-scoped field union for all-organization query planning in one statement', async () => {
|
|
397
|
+
const entityId = 'test:all_org_customer_entity'
|
|
398
|
+
const { em, execute } = makePolicyEm(() => [
|
|
399
|
+
{ entity_id: entityId, tenant_id: 'tenant-all', organization_id: 'org-a', fields_json: [{ field: 'display_name' }, { field: 'primary_email' }] },
|
|
400
|
+
{ entity_id: entityId, tenant_id: 'tenant-all', organization_id: 'org-b', fields_json: [{ field: 'display_name' }, { field: 'description' }, { field: '' }] },
|
|
401
|
+
{ entity_id: entityId, tenant_id: 'other-tenant', organization_id: 'org-c', fields_json: [{ field: 'foreign' }] },
|
|
402
|
+
])
|
|
403
|
+
const service = new TenantDataEncryptionService(em as never)
|
|
376
404
|
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
377
405
|
|
|
378
406
|
await expect(
|
|
379
|
-
service.getEncryptedFieldNames(
|
|
407
|
+
service.getEncryptedFieldNames(entityId, 'tenant-all', null),
|
|
380
408
|
).resolves.toEqual(['display_name', 'primary_email', 'description'])
|
|
381
409
|
|
|
410
|
+
expect(execute).toHaveBeenCalledTimes(1)
|
|
382
411
|
expect(execute).toHaveBeenCalledWith(
|
|
383
|
-
expect.stringContaining('organization_id is
|
|
384
|
-
[
|
|
412
|
+
expect.stringContaining('tenant_id is null and organization_id is null'),
|
|
413
|
+
[entityId, 'tenant-all'],
|
|
414
|
+
)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('resolves the exact, tenant-wide and global fallback chain with one statement', async () => {
|
|
418
|
+
const entityId = 'test:fallback_chain'
|
|
419
|
+
let table: PolicyRow[] = [
|
|
420
|
+
{ entity_id: entityId, tenant_id: null, organization_id: null, fields_json: [{ field: 'global' }] },
|
|
421
|
+
{ entity_id: entityId, tenant_id: 'tenant-1', organization_id: null, fields_json: [{ field: 'tenant_wide' }] },
|
|
422
|
+
{ entity_id: entityId, tenant_id: 'tenant-1', organization_id: 'org-1', fields_json: [{ field: 'exact' }] },
|
|
423
|
+
]
|
|
424
|
+
const { em, execute } = makePolicyEm(() => table)
|
|
425
|
+
const service = new TenantDataEncryptionService(em as never)
|
|
426
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
427
|
+
|
|
428
|
+
await expect(service.getEncryptedFieldNames(entityId, 'tenant-1', 'org-1')).resolves.toEqual(['exact'])
|
|
429
|
+
table = table.filter((row) => row.organization_id !== 'org-1')
|
|
430
|
+
await expect(service.getEncryptedFieldNames(entityId, 'tenant-1', 'org-1')).resolves.toEqual(['tenant_wide'])
|
|
431
|
+
table = table.filter((row) => row.tenant_id !== 'tenant-1')
|
|
432
|
+
await expect(service.getEncryptedFieldNames(entityId, 'tenant-1', 'org-1')).resolves.toEqual(['global'])
|
|
433
|
+
expect(execute).toHaveBeenCalledTimes(3)
|
|
434
|
+
for (const [, params] of execute.mock.calls) expect(params).toEqual([entityId, 'tenant-1', 'org-1'])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('fails closed when the EntityManager cannot execute SQL', async () => {
|
|
438
|
+
const service = new TenantDataEncryptionService({} as never)
|
|
439
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
440
|
+
|
|
441
|
+
await expect(service.getEncryptedFieldNames('test:no_executor', 'tenant-1', 'org-1'))
|
|
442
|
+
.rejects.toThrow('[internal]')
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
describe('TenantDataEncryptionService canonical map reads and invalidation', () => {
|
|
447
|
+
it('re-reads exact and all-organizations policies without waiting for invalidation', async () => {
|
|
448
|
+
const entityId = 'test:cache_invalidation_hit'
|
|
449
|
+
const tenantId = 'tenant-cache-invalidation-hit'
|
|
450
|
+
const organizationId = 'org-cache-invalidation-hit'
|
|
451
|
+
let exactFields = [{ field: 'existing_exact' }]
|
|
452
|
+
let aggregateFields = [{ field: 'existing_aggregate' }]
|
|
453
|
+
const { em } = makePolicyEm(() => [
|
|
454
|
+
{ entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: exactFields },
|
|
455
|
+
{ entity_id: entityId, tenant_id: tenantId, organization_id: 'org-aggregate', fields_json: aggregateFields },
|
|
456
|
+
])
|
|
457
|
+
const service = new TenantDataEncryptionService(em as never)
|
|
458
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
459
|
+
|
|
460
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
461
|
+
.resolves.toEqual(['existing_exact'])
|
|
462
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, null))
|
|
463
|
+
.resolves.toEqual(['existing_exact', 'existing_aggregate'])
|
|
464
|
+
|
|
465
|
+
exactFields = [{ field: 'existing_exact' }, { field: 'fresh_exact' }]
|
|
466
|
+
aggregateFields = [{ field: 'existing_aggregate' }, { field: 'fresh_aggregate' }]
|
|
467
|
+
|
|
468
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
469
|
+
.resolves.toEqual(['existing_exact', 'fresh_exact'])
|
|
470
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, null))
|
|
471
|
+
.resolves.toEqual(['existing_exact', 'fresh_exact', 'existing_aggregate', 'fresh_aggregate'])
|
|
472
|
+
|
|
473
|
+
await service.invalidateMap(entityId, tenantId, organizationId)
|
|
474
|
+
|
|
475
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
476
|
+
.resolves.toEqual(['existing_exact', 'fresh_exact'])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('does not retain an exact miss after a map is committed', async () => {
|
|
480
|
+
const entityId = 'test:cache_invalidation_miss'
|
|
481
|
+
const tenantId = 'tenant-cache-invalidation-miss'
|
|
482
|
+
const organizationId = 'org-cache-invalidation-miss'
|
|
483
|
+
let table: PolicyRow[] = []
|
|
484
|
+
const { em } = makePolicyEm(() => table)
|
|
485
|
+
const service = new TenantDataEncryptionService(em as never)
|
|
486
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
487
|
+
|
|
488
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId)).resolves.toEqual([])
|
|
489
|
+
table = [{ entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: [{ field: 'fresh_after_miss' }] }]
|
|
490
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
491
|
+
.resolves.toEqual(['fresh_after_miss'])
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('never reads, publishes or deletes shared-cache entries', async () => {
|
|
495
|
+
const entityId = 'test:canonical_policy_over_shared_cache'
|
|
496
|
+
const tenantId = 'tenant-canonical-policy'
|
|
497
|
+
const organizationId = 'org-canonical-policy'
|
|
498
|
+
const cache = {
|
|
499
|
+
get: jest.fn(async () => ({ entityId, fields: [{ field: 'stale_shared_cache' }] })),
|
|
500
|
+
set: jest.fn(async () => undefined),
|
|
501
|
+
delete: jest.fn(async () => {
|
|
502
|
+
throw new Error('sensitive backend endpoint')
|
|
503
|
+
}),
|
|
504
|
+
}
|
|
505
|
+
const { em } = makePolicyEm(() => [
|
|
506
|
+
{ entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: [{ field: 'fresh_exact' }] },
|
|
507
|
+
])
|
|
508
|
+
const service = new TenantDataEncryptionService(em as never, { cache: cache as never })
|
|
509
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
510
|
+
|
|
511
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
512
|
+
.resolves.toEqual(['fresh_exact'])
|
|
513
|
+
await expect(service.invalidateMap(entityId, tenantId, organizationId)).resolves.toBeUndefined()
|
|
514
|
+
expect(cache.get).not.toHaveBeenCalled()
|
|
515
|
+
expect(cache.set).not.toHaveBeenCalled()
|
|
516
|
+
expect(cache.delete).not.toHaveBeenCalled()
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
describe('TenantDataEncryptionService per-unit-of-work policy memo', () => {
|
|
521
|
+
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
522
|
+
const tenantId = 'tenant-memo'
|
|
523
|
+
const organizationId = 'org-memo'
|
|
524
|
+
|
|
525
|
+
beforeEach(() => {
|
|
526
|
+
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
afterEach(() => {
|
|
530
|
+
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
531
|
+
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
function makeService(entityId: string, table: { rows: PolicyRow[] }) {
|
|
535
|
+
const { execute } = makePolicyEm(() => table.rows)
|
|
536
|
+
const service = new TenantDataEncryptionService({ execute } as never, {
|
|
537
|
+
kms: {
|
|
538
|
+
getTenantDek: jest.fn(async (keyId: string) => ({ tenantId: keyId, key: fixedKey, fetchedAt: Date.now() })),
|
|
539
|
+
createTenantDek: jest.fn(async () => null),
|
|
540
|
+
isHealthy: () => true,
|
|
541
|
+
},
|
|
542
|
+
} as never)
|
|
543
|
+
table.rows.push({ entity_id: entityId, tenant_id: tenantId, organization_id: null, fields_json: [{ field: 'secret' }] })
|
|
544
|
+
return { service, execute }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function makeUnitOfWork(execute: jest.Mock) {
|
|
548
|
+
return { execute } as never
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
it('reads policy once per distinct scope for N decrypts on the same EntityManager', async () => {
|
|
552
|
+
const entityId = 'test:memo_same_em'
|
|
553
|
+
const table = { rows: [] as PolicyRow[] }
|
|
554
|
+
const { service, execute } = makeService(entityId, table)
|
|
555
|
+
const em = makeUnitOfWork(execute)
|
|
556
|
+
const ciphertext = encryptWithAesGcm('value', fixedKey).value as string
|
|
557
|
+
|
|
558
|
+
const decrypted = await Promise.all(Array.from({ length: 25 }, () => (
|
|
559
|
+
service.decryptEntityPayload(entityId, { secret: ciphertext }, tenantId, organizationId, { em })
|
|
560
|
+
)))
|
|
561
|
+
await service.decryptEntityPayload(entityId, { secret: ciphertext }, tenantId, null, { em })
|
|
562
|
+
await service.encryptEntityPayload(entityId, { secret: 'value' }, tenantId, organizationId, { em })
|
|
563
|
+
|
|
564
|
+
expect(decrypted.every((row) => row.secret === 'value')).toBe(true)
|
|
565
|
+
expect(execute).toHaveBeenCalledTimes(2)
|
|
566
|
+
expect(execute.mock.calls.map(([, params]) => params)).toEqual([
|
|
567
|
+
[entityId, tenantId, organizationId],
|
|
568
|
+
[entityId, tenantId],
|
|
569
|
+
])
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('keeps memos of different EntityManagers and transactions independent', async () => {
|
|
573
|
+
const entityId = 'test:memo_independent_em'
|
|
574
|
+
const table = { rows: [] as PolicyRow[] }
|
|
575
|
+
const { service, execute } = makeService(entityId, table)
|
|
576
|
+
const first = makeUnitOfWork(execute)
|
|
577
|
+
const second = makeUnitOfWork(execute)
|
|
578
|
+
const transaction = {}
|
|
579
|
+
const transactional = { execute, getTransactionContext: () => transaction } as never
|
|
580
|
+
|
|
581
|
+
await service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: first })
|
|
582
|
+
table.rows[0].fields_json = [{ field: 'secret' }, { field: 'rotated' }]
|
|
583
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: first }))
|
|
584
|
+
.resolves.toEqual(['secret'])
|
|
585
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: second }))
|
|
586
|
+
.resolves.toEqual(['secret', 'rotated'])
|
|
587
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: transactional }))
|
|
588
|
+
.resolves.toEqual(['secret', 'rotated'])
|
|
589
|
+
expect(execute).toHaveBeenCalledTimes(3)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('never memoizes lookups that fall back to the service EntityManager', async () => {
|
|
593
|
+
const entityId = 'test:memo_fallback_em'
|
|
594
|
+
const table = { rows: [] as PolicyRow[] }
|
|
595
|
+
const { service, execute } = makeService(entityId, table)
|
|
596
|
+
|
|
597
|
+
await service.getEncryptedFieldNames(entityId, tenantId, organizationId)
|
|
598
|
+
await service.getEncryptedFieldNames(entityId, tenantId, organizationId)
|
|
599
|
+
|
|
600
|
+
expect(execute).toHaveBeenCalledTimes(2)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('drops the memo when a map write goes through that EntityManager', async () => {
|
|
604
|
+
const entityId = 'test:memo_write_forget'
|
|
605
|
+
const table = { rows: [] as PolicyRow[] }
|
|
606
|
+
const { service, execute } = makeService(entityId, table)
|
|
607
|
+
const em = makeUnitOfWork(execute)
|
|
608
|
+
|
|
609
|
+
await service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em })
|
|
610
|
+
table.rows[0].fields_json = [{ field: 'secret' }, { field: 'added' }]
|
|
611
|
+
forgetEncryptionPolicyMemo(em)
|
|
612
|
+
|
|
613
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
|
|
614
|
+
.resolves.toEqual(['secret', 'added'])
|
|
615
|
+
expect(execute).toHaveBeenCalledTimes(2)
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('drops every memo of this process when a map is invalidated', async () => {
|
|
619
|
+
const entityId = 'test:memo_invalidate'
|
|
620
|
+
const table = { rows: [] as PolicyRow[] }
|
|
621
|
+
const { service, execute } = makeService(entityId, table)
|
|
622
|
+
const em = makeUnitOfWork(execute)
|
|
623
|
+
|
|
624
|
+
await service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em })
|
|
625
|
+
table.rows[0].fields_json = [{ field: 'secret' }, { field: 'added' }]
|
|
626
|
+
await service.invalidateMap(entityId, tenantId, null)
|
|
627
|
+
|
|
628
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
|
|
629
|
+
.resolves.toEqual(['secret', 'added'])
|
|
630
|
+
expect(execute).toHaveBeenCalledTimes(2)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('does not memoize a failed read', async () => {
|
|
634
|
+
const entityId = 'test:memo_failed_read'
|
|
635
|
+
const table = { rows: [] as PolicyRow[] }
|
|
636
|
+
const { service, execute } = makeService(entityId, table)
|
|
637
|
+
execute.mockRejectedValueOnce(new Error('connection terminated'))
|
|
638
|
+
const em = makeUnitOfWork(execute)
|
|
639
|
+
|
|
640
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
|
|
641
|
+
.rejects.toThrow('connection terminated')
|
|
642
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
|
|
643
|
+
.resolves.toEqual(['secret'])
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
describe('TenantDataEncryptionService duplicate map fail-safe', () => {
|
|
648
|
+
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
649
|
+
|
|
650
|
+
beforeEach(() => {
|
|
651
|
+
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
afterEach(() => {
|
|
655
|
+
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
656
|
+
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it('deterministically unions active duplicate rows so no declared field is stored as plaintext', async () => {
|
|
660
|
+
const entityId = 'test:duplicate_encryption_map'
|
|
661
|
+
const tenantId = 'tenant-duplicate-map'
|
|
662
|
+
const execute = jest.fn(async (sql: string) => {
|
|
663
|
+
expect(sql).toContain('order by organization_id asc, created_at asc, id asc')
|
|
664
|
+
return [
|
|
665
|
+
{
|
|
666
|
+
entity_id: entityId,
|
|
667
|
+
tenant_id: tenantId,
|
|
668
|
+
organization_id: 'org-1',
|
|
669
|
+
fields_json: [
|
|
670
|
+
{ field: 'email' },
|
|
671
|
+
{ field: 'display_name', hashField: 'display_name_hash' },
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
entity_id: entityId,
|
|
676
|
+
tenant_id: tenantId,
|
|
677
|
+
organization_id: 'org-1',
|
|
678
|
+
fields_json: [
|
|
679
|
+
{ field: 'email', hashField: 'email_hash' },
|
|
680
|
+
{ field: 'phone' },
|
|
681
|
+
],
|
|
682
|
+
},
|
|
683
|
+
]
|
|
684
|
+
})
|
|
685
|
+
const service = new TenantDataEncryptionService(
|
|
686
|
+
{ execute, getConnection: () => ({ execute }) } as never,
|
|
687
|
+
{
|
|
688
|
+
kms: {
|
|
689
|
+
getTenantDek: jest.fn(async () => ({ tenantId, key: fixedKey, fetchedAt: new Date() })),
|
|
690
|
+
createTenantDek: jest.fn(async () => null),
|
|
691
|
+
isHealthy: () => true,
|
|
692
|
+
},
|
|
693
|
+
} as never,
|
|
385
694
|
)
|
|
695
|
+
|
|
696
|
+
const encrypted = await service.encryptEntityPayload(
|
|
697
|
+
entityId,
|
|
698
|
+
{
|
|
699
|
+
email: 'person@example.com',
|
|
700
|
+
email_hash: null,
|
|
701
|
+
display_name: 'Person',
|
|
702
|
+
display_name_hash: null,
|
|
703
|
+
phone: '+48123456789',
|
|
704
|
+
},
|
|
705
|
+
tenantId,
|
|
706
|
+
'org-1',
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
expect(decryptWithAesGcm(encrypted.email as string, fixedKey)).toBe('person@example.com')
|
|
710
|
+
expect(encrypted.email_hash).toBe(hashForLookup('person@example.com'))
|
|
711
|
+
expect(decryptWithAesGcm(encrypted.display_name as string, fixedKey)).toBe('Person')
|
|
712
|
+
expect(encrypted.display_name_hash).toBe(hashForLookup('Person'))
|
|
713
|
+
expect(decryptWithAesGcm(encrypted.phone as string, fixedKey)).toBe('+48123456789')
|
|
386
714
|
})
|
|
387
715
|
})
|
|
388
716
|
|
|
@@ -400,19 +728,20 @@ describe('TenantDataEncryptionService tenant-wide scope parity (issue #5949)', (
|
|
|
400
728
|
})
|
|
401
729
|
|
|
402
730
|
// A base (organization-less) map declaring `display_name`, plus an organization-scoped map that
|
|
403
|
-
// declares the extra `description` field.
|
|
404
|
-
//
|
|
731
|
+
// declares the extra `description` field. At organizationId = null the policy statement also
|
|
732
|
+
// returns the organization-scoped rows (2-parameter form) for the all-organizations aggregate.
|
|
405
733
|
function makeService(entityId: string) {
|
|
406
|
-
const execute =
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
734
|
+
const { execute } = makePolicyEm(() => [
|
|
735
|
+
{ entity_id: entityId, tenant_id: tenantId, organization_id: null, fields_json: [{ field: 'display_name' }] },
|
|
736
|
+
{
|
|
737
|
+
entity_id: entityId,
|
|
738
|
+
tenant_id: tenantId,
|
|
739
|
+
organization_id: 'org-with-description',
|
|
740
|
+
fields_json: [{ field: 'description', hashField: 'description_hash' }],
|
|
741
|
+
},
|
|
742
|
+
])
|
|
414
743
|
const service = new TenantDataEncryptionService(
|
|
415
|
-
{
|
|
744
|
+
{ execute } as never,
|
|
416
745
|
{
|
|
417
746
|
kms: {
|
|
418
747
|
getTenantDek: jest.fn(async (keyId: string) => (
|
|
@@ -494,31 +823,63 @@ describe('TenantDataEncryptionService tenant-wide scope parity (issue #5949)', (
|
|
|
494
823
|
expect(execute).not.toHaveBeenCalledWith(expect.anything(), [entityId, tenantId])
|
|
495
824
|
})
|
|
496
825
|
|
|
497
|
-
it('
|
|
498
|
-
const entityId = 'test:
|
|
826
|
+
it('re-reads the all-organizations aggregate across payload calls', async () => {
|
|
827
|
+
const entityId = 'test:parity_canonical_entity'
|
|
499
828
|
const { service, execute } = makeService(entityId)
|
|
500
829
|
|
|
501
830
|
await service.encryptEntityPayload(entityId, { description: 'first' }, tenantId, null)
|
|
502
831
|
await service.encryptEntityPayload(entityId, { description: 'second' }, tenantId, null)
|
|
503
832
|
|
|
504
833
|
const aggregateReads = execute.mock.calls.filter(([, params]) => (params as unknown[]).length === 2)
|
|
505
|
-
expect(aggregateReads).toHaveLength(
|
|
834
|
+
expect(aggregateReads).toHaveLength(2)
|
|
506
835
|
})
|
|
836
|
+
})
|
|
507
837
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
838
|
+
describe('TenantDataEncryptionService map read failures (issue #6334)', () => {
|
|
839
|
+
const tenantId = 'tenant-6334'
|
|
840
|
+
const organizationId = 'org-6334'
|
|
841
|
+
const mapRow = (entityId: string) => [{
|
|
842
|
+
entity_id: entityId,
|
|
843
|
+
tenant_id: tenantId,
|
|
844
|
+
organization_id: organizationId,
|
|
845
|
+
fields_json: [{ field: 'display_name' }],
|
|
846
|
+
}]
|
|
847
|
+
|
|
848
|
+
function makeService(execute: jest.Mock) {
|
|
849
|
+
const service = new TenantDataEncryptionService({ execute, getConnection: () => ({ execute }) } as never)
|
|
850
|
+
jest.spyOn(service, 'isEnabled').mockReturnValue(true)
|
|
851
|
+
return service
|
|
852
|
+
}
|
|
520
853
|
|
|
521
|
-
|
|
522
|
-
|
|
854
|
+
it('retries the map read after a failed one instead of replaying the stored rejection', async () => {
|
|
855
|
+
const entityId = 'test:inflight_rejection_entity'
|
|
856
|
+
const execute = jest.fn()
|
|
857
|
+
.mockRejectedValueOnce(new Error('connection terminated'))
|
|
858
|
+
.mockImplementation(async () => mapRow(entityId))
|
|
859
|
+
const service = makeService(execute)
|
|
860
|
+
|
|
861
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
862
|
+
.rejects.toThrow('connection terminated')
|
|
863
|
+
await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
|
|
864
|
+
.resolves.toEqual(['display_name'])
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('does not couple a concurrent reader to another failing read', async () => {
|
|
868
|
+
const entityId = 'test:inflight_joined_rejection_entity'
|
|
869
|
+
let rejectFirst: (error: Error) => void = () => {}
|
|
870
|
+
const execute = jest.fn()
|
|
871
|
+
.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
|
|
872
|
+
.mockImplementation(async () => mapRow(entityId))
|
|
873
|
+
const service = makeService(execute)
|
|
874
|
+
|
|
875
|
+
const owner = service.getEncryptedFieldNames(entityId, tenantId, organizationId)
|
|
876
|
+
const joiner = service.getEncryptedFieldNames(entityId, tenantId, organizationId)
|
|
877
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
878
|
+
expect(execute).toHaveBeenCalledTimes(2)
|
|
879
|
+
await expect(joiner).resolves.toEqual(['display_name'])
|
|
880
|
+
rejectFirst(new Error('statement timeout'))
|
|
881
|
+
|
|
882
|
+
await expect(owner).rejects.toThrow('statement timeout')
|
|
883
|
+
expect(execute).toHaveBeenCalledTimes(2)
|
|
523
884
|
})
|
|
524
885
|
})
|
|
@@ -388,17 +388,12 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
388
388
|
|
|
389
389
|
let loggedDerivedKeyFallbackBanner = false
|
|
390
390
|
|
|
391
|
-
function fingerprintSecret(secret: string): string {
|
|
392
|
-
return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
391
|
export function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {
|
|
396
392
|
const sourceLine =
|
|
397
393
|
opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'
|
|
398
394
|
return [
|
|
399
395
|
'🚨 Using derived tenant encryption keys (Vault unavailable / no DEK)',
|
|
400
396
|
sourceLine,
|
|
401
|
-
`Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,
|
|
402
397
|
'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',
|
|
403
398
|
]
|
|
404
399
|
}
|
|
@@ -419,7 +414,7 @@ function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
|
|
|
419
414
|
]
|
|
420
415
|
process.stderr.write(bannerLines.join('\n') + '\n')
|
|
421
416
|
logger.warn('Using derived tenant encryption keys (Vault unavailable / no DEK)', {
|
|
422
|
-
|
|
417
|
+
fallbackSource: opts.envName,
|
|
423
418
|
})
|
|
424
419
|
}
|
|
425
420
|
|
|
@@ -73,17 +73,25 @@ function resolveEntityId(em: EntityManager, entityName: EntityName<any>): string
|
|
|
73
73
|
* already warn about their own `ILIKE` fallback; this covers the raw-ORM path
|
|
74
74
|
* that never reaches them. Issue #5051, related to #2990.
|
|
75
75
|
*
|
|
76
|
+
* A caller whose predicate the walker cannot read — a `raw()` fragment keyed by
|
|
77
|
+
* a symbol, say, which `Object.entries` never yields — passes the matched
|
|
78
|
+
* properties as `likeFields` instead, so wrapping a filter in raw SQL does not
|
|
79
|
+
* silently opt it out of the diagnostic.
|
|
80
|
+
*
|
|
76
81
|
* Never throws and never blocks the query.
|
|
77
82
|
*/
|
|
78
83
|
export async function warnOnEncryptedLikeFilter(params: {
|
|
79
84
|
em: EntityManager
|
|
80
85
|
entityName: EntityName<any>
|
|
81
|
-
where
|
|
86
|
+
where?: unknown
|
|
87
|
+
likeFields?: readonly string[]
|
|
82
88
|
tenantId?: string | null
|
|
83
89
|
encryptionService?: TenantDataEncryptionService | null
|
|
84
90
|
}): Promise<void> {
|
|
85
91
|
if (!isWarningEnabled()) return
|
|
86
|
-
const fields =
|
|
92
|
+
const fields = Array.from(
|
|
93
|
+
new Set([...collectLikeFilterFields(params.where), ...(params.likeFields ?? [])]),
|
|
94
|
+
)
|
|
87
95
|
if (!fields.length) return
|
|
88
96
|
try {
|
|
89
97
|
const entity = resolveEntityId(params.em, params.entityName)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ModuleEncryptionFieldRule, ModuleEncryptionMap } from '../../modules/encryption'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tenant encryption maps are seeded only when a tenant is created (`auth:setup`) or by hand
|
|
5
|
+
* (`mercato entities seed-encryption`). A map entry added to a module after tenants exist therefore
|
|
6
|
+
* never reaches them: `encryptEntityPayload` finds no rule and the column is written as plaintext.
|
|
7
|
+
*
|
|
8
|
+
* `buildEncryptionMapBackfillSql` emits one idempotent statement for a module migration that closes
|
|
9
|
+
* that gap for every pre-existing (tenant, organization) scope with active maps:
|
|
10
|
+
* - inserts the whole map where the scope has no row for the entity yet (`NOT EXISTS` guard);
|
|
11
|
+
* - appends each missing field rule to rows that already exist, leaving other rules untouched.
|
|
12
|
+
*
|
|
13
|
+
* Scopes without active maps (encryption disabled) are skipped, mirroring `seed-encryption`. When the
|
|
14
|
+
* `encryption_maps` table does not exist yet (a fresh database where this module migrates before
|
|
15
|
+
* `entities`), the statement is a no-op: no tenant exists at that point either.
|
|
16
|
+
*
|
|
17
|
+
* Prefer a forward-only migration (`down()` without SQL): deleting by `entity_id` would also remove
|
|
18
|
+
* maps seeded at tenant creation or edited by admins and silently switch encryption off.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ENTITY_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/
|
|
22
|
+
const FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
23
|
+
|
|
24
|
+
function assertIdentifier(value: unknown, pattern: RegExp, label: string): string {
|
|
25
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
26
|
+
throw new Error(`[internal] buildEncryptionMapBackfillSql received an invalid ${label}: ${String(value)}`)
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRules(map: ModuleEncryptionMap): ModuleEncryptionFieldRule[] {
|
|
32
|
+
if (!Array.isArray(map.fields) || map.fields.length === 0) {
|
|
33
|
+
throw new Error(`[internal] buildEncryptionMapBackfillSql requires at least one field for ${map.entityId}`)
|
|
34
|
+
}
|
|
35
|
+
const rules: ModuleEncryptionFieldRule[] = []
|
|
36
|
+
for (const rule of map.fields) {
|
|
37
|
+
const field = assertIdentifier(rule?.field, FIELD_NAME_PATTERN, 'field name')
|
|
38
|
+
if (rules.some((existing) => existing.field === field)) continue
|
|
39
|
+
const hashField = rule.hashField == null ? null : assertIdentifier(rule.hashField, FIELD_NAME_PATTERN, 'hash field name')
|
|
40
|
+
rules.push(hashField ? { field, hashField } : { field })
|
|
41
|
+
}
|
|
42
|
+
return rules
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function jsonbLiteral(value: unknown): string {
|
|
46
|
+
return `'${JSON.stringify(value)}'::jsonb`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildEncryptionMapBackfillSql(map: ModuleEncryptionMap): string {
|
|
50
|
+
const entityId = assertIdentifier(map?.entityId, ENTITY_ID_PATTERN, 'entity id')
|
|
51
|
+
if (map.keyScope === 'system') {
|
|
52
|
+
throw new Error(`[internal] buildEncryptionMapBackfillSql: ${entityId} is system-scoped and is resolved from module code, not encryption_maps`)
|
|
53
|
+
}
|
|
54
|
+
const rules = normalizeRules(map)
|
|
55
|
+
const appendStatements = rules.map((rule) => `
|
|
56
|
+
update "encryption_maps"
|
|
57
|
+
set "fields_json" = coalesce("fields_json", '[]'::jsonb) || ${jsonbLiteral([rule])}, "updated_at" = now()
|
|
58
|
+
where "entity_id" = '${entityId}'
|
|
59
|
+
and "deleted_at" is null
|
|
60
|
+
and jsonb_typeof(coalesce("fields_json", '[]'::jsonb)) = 'array'
|
|
61
|
+
and not exists (
|
|
62
|
+
select 1
|
|
63
|
+
from jsonb_array_elements(case when jsonb_typeof("fields_json") = 'array' then "fields_json" else '[]'::jsonb end) as rule
|
|
64
|
+
where rule->>'field' = '${rule.field}'
|
|
65
|
+
);`).join('\n')
|
|
66
|
+
|
|
67
|
+
return `do $$
|
|
68
|
+
begin
|
|
69
|
+
if to_regclass('encryption_maps') is null then
|
|
70
|
+
return;
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
insert into "encryption_maps" ("id", "entity_id", "tenant_id", "organization_id", "fields_json", "is_active", "created_at", "updated_at")
|
|
74
|
+
select gen_random_uuid(), '${entityId}', src."tenant_id", src."organization_id", ${jsonbLiteral(rules)}, true, now(), now()
|
|
75
|
+
from (
|
|
76
|
+
select distinct "tenant_id", "organization_id"
|
|
77
|
+
from "encryption_maps"
|
|
78
|
+
where "is_active" = true and "deleted_at" is null
|
|
79
|
+
) src
|
|
80
|
+
where not exists (
|
|
81
|
+
select 1 from "encryption_maps" existing
|
|
82
|
+
where existing."entity_id" = '${entityId}'
|
|
83
|
+
and existing."tenant_id" is not distinct from src."tenant_id"
|
|
84
|
+
and existing."organization_id" is not distinct from src."organization_id"
|
|
85
|
+
and existing."deleted_at" is null
|
|
86
|
+
);
|
|
87
|
+
${appendStatements}
|
|
88
|
+
end
|
|
89
|
+
$$;`
|
|
90
|
+
}
|