@open-mercato/shared 0.6.4-develop.4038.1.91ce075c8a → 0.6.4-develop.4106.1.12c205a613
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/dist/lib/encryption/kms.js +3 -4
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +6 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/query/encrypted-sort.js +64 -0
- package/dist/lib/query/encrypted-sort.js.map +7 -0
- package/dist/lib/query/engine.js +32 -10
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/encryption/__tests__/kms.test.ts +38 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +21 -0
- package/src/lib/encryption/kms.ts +5 -3
- package/src/lib/encryption/tenantDataEncryptionService.ts +13 -0
- package/src/lib/query/__tests__/engine.test.ts +86 -0
- package/src/lib/query/encrypted-sort.ts +86 -0
- package/src/lib/query/engine.ts +44 -13
|
@@ -516,4 +516,90 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
516
516
|
: false
|
|
517
517
|
expect(hasInFilter).toBe(true)
|
|
518
518
|
})
|
|
519
|
+
|
|
520
|
+
test('sorts encrypted base fields after decryption before pagination', async () => {
|
|
521
|
+
const fakeDb = createFakeKysely({
|
|
522
|
+
customer_entities: [
|
|
523
|
+
{ id: '3', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-c' },
|
|
524
|
+
{ id: '1', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-a' },
|
|
525
|
+
{ id: '5', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-e' },
|
|
526
|
+
{ id: '2', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-b' },
|
|
527
|
+
{ id: '4', tenant_id: 't1', organization_id: 'org1', display_name: 'cipher-d' },
|
|
528
|
+
],
|
|
529
|
+
'information_schema.columns': [
|
|
530
|
+
{ table_name: 'customer_entities', column_name: 'id' },
|
|
531
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
532
|
+
{ table_name: 'customer_entities', column_name: 'organization_id' },
|
|
533
|
+
{ table_name: 'customer_entities', column_name: 'deleted_at' },
|
|
534
|
+
{ table_name: 'customer_entities', column_name: 'display_name' },
|
|
535
|
+
],
|
|
536
|
+
})
|
|
537
|
+
const namesById: Record<string, string> = {
|
|
538
|
+
'1': 'Alice',
|
|
539
|
+
'2': 'Bob',
|
|
540
|
+
'3': 'Charlie',
|
|
541
|
+
'4': 'Dave',
|
|
542
|
+
'5': 'Eve',
|
|
543
|
+
}
|
|
544
|
+
const engine = new BasicQueryEngine(
|
|
545
|
+
{} as any,
|
|
546
|
+
() => fakeDb as any,
|
|
547
|
+
() => ({
|
|
548
|
+
isEnabled: () => true,
|
|
549
|
+
getEncryptedFieldNames: async () => ['display_name'],
|
|
550
|
+
decryptEntityPayload: async (_entityId, payload) => ({
|
|
551
|
+
display_name: namesById[String(payload.id)],
|
|
552
|
+
}),
|
|
553
|
+
}),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const result = await engine.query('customers:customer_entity', {
|
|
557
|
+
tenantId: 't1',
|
|
558
|
+
organizationId: 'org1',
|
|
559
|
+
fields: ['id', 'display_name'],
|
|
560
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
561
|
+
page: { page: 2, pageSize: 2 },
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
expect(result.items.map((item: any) => item.display_name)).toEqual(['Charlie', 'Dave'])
|
|
565
|
+
const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'customer_entities')
|
|
566
|
+
expect(baseCall._ops.orderBys).toEqual([])
|
|
567
|
+
expect(baseCall._ops.limits).toBe(0)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('keeps SQL ordering and pagination for unencrypted base fields', async () => {
|
|
571
|
+
const fakeDb = createFakeKysely({
|
|
572
|
+
customer_entities: [
|
|
573
|
+
{ id: '1', tenant_id: 't1', organization_id: 'org1', display_name: 'Alice' },
|
|
574
|
+
],
|
|
575
|
+
'information_schema.columns': [
|
|
576
|
+
{ table_name: 'customer_entities', column_name: 'id' },
|
|
577
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
578
|
+
{ table_name: 'customer_entities', column_name: 'organization_id' },
|
|
579
|
+
{ table_name: 'customer_entities', column_name: 'deleted_at' },
|
|
580
|
+
{ table_name: 'customer_entities', column_name: 'display_name' },
|
|
581
|
+
],
|
|
582
|
+
})
|
|
583
|
+
const engine = new BasicQueryEngine(
|
|
584
|
+
{} as any,
|
|
585
|
+
() => fakeDb as any,
|
|
586
|
+
() => ({
|
|
587
|
+
isEnabled: () => true,
|
|
588
|
+
getEncryptedFieldNames: async () => [],
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
await engine.query('customers:customer_entity', {
|
|
593
|
+
tenantId: 't1',
|
|
594
|
+
organizationId: 'org1',
|
|
595
|
+
fields: ['id', 'display_name'],
|
|
596
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
597
|
+
page: { page: 2, pageSize: 10 },
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'customer_entities')
|
|
601
|
+
expect(baseCall._ops.orderBys).toEqual([['customer_entities.display_name', 'asc']])
|
|
602
|
+
expect(baseCall._ops.limits).toBe(10)
|
|
603
|
+
expect(baseCall._ops.offsets).toBe(10)
|
|
604
|
+
})
|
|
519
605
|
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
2
|
+
import { SortDir, type Sort } from './types'
|
|
3
|
+
|
|
4
|
+
export type QueryEncryptionService = {
|
|
5
|
+
getEncryptedFieldNames?: (
|
|
6
|
+
entityId: EntityId,
|
|
7
|
+
tenantId?: string | null,
|
|
8
|
+
organizationId?: string | null,
|
|
9
|
+
) => Promise<readonly string[]>
|
|
10
|
+
isEnabled?: () => boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const toSnakeCase = (value: string): string =>
|
|
14
|
+
value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()
|
|
15
|
+
|
|
16
|
+
const toCamelCase = (value: string): string =>
|
|
17
|
+
value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
18
|
+
|
|
19
|
+
export function fieldNameCandidates(field: string): string[] {
|
|
20
|
+
const raw = String(field || '').trim()
|
|
21
|
+
if (!raw) return []
|
|
22
|
+
const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)]
|
|
23
|
+
if (raw.startsWith('cf:')) candidates.push(raw.replace(/[^a-zA-Z0-9_]/g, '_'))
|
|
24
|
+
return Array.from(new Set(candidates))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function resolveEncryptedSortFields(
|
|
28
|
+
service: QueryEncryptionService | null | undefined,
|
|
29
|
+
entity: EntityId,
|
|
30
|
+
sortFields: readonly string[],
|
|
31
|
+
tenantId?: string | null,
|
|
32
|
+
organizationId?: string | null,
|
|
33
|
+
): Promise<Set<string>> {
|
|
34
|
+
if (!service?.getEncryptedFieldNames) return new Set()
|
|
35
|
+
if (service.isEnabled && !service.isEnabled()) return new Set()
|
|
36
|
+
const encrypted = await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)
|
|
37
|
+
const encryptedCandidates = new Set<string>()
|
|
38
|
+
for (const field of encrypted) {
|
|
39
|
+
for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)
|
|
40
|
+
}
|
|
41
|
+
const result = new Set<string>()
|
|
42
|
+
for (const field of sortFields) {
|
|
43
|
+
if (field.startsWith('cf:')) continue
|
|
44
|
+
const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))
|
|
45
|
+
if (matches) result.add(field)
|
|
46
|
+
}
|
|
47
|
+
return result
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readField(row: Record<string, unknown>, field: string): unknown {
|
|
51
|
+
for (const candidate of fieldNameCandidates(field)) {
|
|
52
|
+
if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function compareValues(left: unknown, right: unknown): number {
|
|
58
|
+
const leftMissing = left === null || left === undefined
|
|
59
|
+
const rightMissing = right === null || right === undefined
|
|
60
|
+
if (leftMissing && rightMissing) return 0
|
|
61
|
+
if (leftMissing) return 1
|
|
62
|
+
if (rightMissing) return -1
|
|
63
|
+
if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()
|
|
64
|
+
if (typeof left === 'number' && typeof right === 'number') return left - right
|
|
65
|
+
if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)
|
|
66
|
+
return String(left).localeCompare(String(right), undefined, {
|
|
67
|
+
sensitivity: 'base',
|
|
68
|
+
numeric: true,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sortRowsInMemory<T extends Record<string, unknown>>(
|
|
73
|
+
rows: readonly T[],
|
|
74
|
+
sorts: readonly Sort[],
|
|
75
|
+
): T[] {
|
|
76
|
+
return [...rows].sort((left, right) => {
|
|
77
|
+
for (const sort of sorts) {
|
|
78
|
+
const direction = sort.dir === SortDir.Desc ? -1 : 1
|
|
79
|
+
const compared = compareValues(readField(left, sort.field), readField(right, sort.field))
|
|
80
|
+
if (compared !== 0) return compared * direction
|
|
81
|
+
}
|
|
82
|
+
const leftId = readField(left, 'id')
|
|
83
|
+
const rightId = readField(right, 'id')
|
|
84
|
+
return compareValues(leftId, rightId)
|
|
85
|
+
})
|
|
86
|
+
}
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource, QueryExtensionsConfig } from './types'
|
|
1
|
+
import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource, QueryExtensionsConfig, Sort } from './types'
|
|
2
2
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { type Kysely, sql, type RawBuilder } from 'kysely'
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { resolveSearchConfig } from '../search/config'
|
|
15
15
|
import { tokenizeText } from '../search/tokenize'
|
|
16
16
|
import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
|
|
17
|
+
import { resolveEncryptedSortFields, sortRowsInMemory } from './encrypted-sort'
|
|
17
18
|
|
|
18
19
|
type AnyDb = Kysely<any>
|
|
19
20
|
type AnyBuilder = any
|
|
@@ -22,6 +23,7 @@ const entityTableCache = new Map<string, string>()
|
|
|
22
23
|
|
|
23
24
|
type EncryptionResolver = () => {
|
|
24
25
|
decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
|
|
26
|
+
getEncryptedFieldNames?: (entityId: EntityId, tenantId?: string | null, organizationId?: string | null) => Promise<readonly string[]>
|
|
25
27
|
isEnabled?: () => boolean
|
|
26
28
|
} | null
|
|
27
29
|
|
|
@@ -475,9 +477,35 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
475
477
|
applyJoinFilterOp,
|
|
476
478
|
columnExists: (tbl, column) => this.columnExists(tbl, column),
|
|
477
479
|
})
|
|
480
|
+
|
|
481
|
+
const fallbackOrgId =
|
|
482
|
+
opts.organizationId
|
|
483
|
+
?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
|
|
484
|
+
const encryptionService = this.getEncryptionService()
|
|
485
|
+
const resolvedSorts: Sort[] = []
|
|
486
|
+
for (const s of opts.sort || []) {
|
|
487
|
+
if (s.field.startsWith('cf:')) {
|
|
488
|
+
resolvedSorts.push(s)
|
|
489
|
+
} else {
|
|
490
|
+
const column = await this.resolveBaseColumn(table, s.field)
|
|
491
|
+
if (column) resolvedSorts.push({ ...s, field: column })
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const encryptedSortFields = await resolveEncryptedSortFields(
|
|
495
|
+
encryptionService,
|
|
496
|
+
entity,
|
|
497
|
+
resolvedSorts.filter((sort) => !sort.field.startsWith('cf:')).map((sort) => sort.field),
|
|
498
|
+
opts.tenantId ?? null,
|
|
499
|
+
fallbackOrgId,
|
|
500
|
+
)
|
|
501
|
+
const requiresPlaintextSort = encryptedSortFields.size > 0
|
|
502
|
+
|
|
478
503
|
// Selection (base columns only here; cf:* handled later)
|
|
479
504
|
if (opts.fields && opts.fields.length) {
|
|
480
|
-
const cols = opts.fields.filter((f) => !f.startsWith('cf:'))
|
|
505
|
+
const cols = new Set(opts.fields.filter((f) => !f.startsWith('cf:')))
|
|
506
|
+
if (requiresPlaintextSort) {
|
|
507
|
+
for (const field of encryptedSortFields) cols.add(field)
|
|
508
|
+
}
|
|
481
509
|
for (const c of cols) {
|
|
482
510
|
// Qualify and alias to base names to avoid ambiguity
|
|
483
511
|
q = q.select(sql.ref(qualify(c)).as(c))
|
|
@@ -714,7 +742,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
714
742
|
}
|
|
715
743
|
|
|
716
744
|
// Sorting: base fields and cf:* (use aggregated alias for cf)
|
|
717
|
-
for (const s of
|
|
745
|
+
for (const s of resolvedSorts) {
|
|
718
746
|
if (s.field.startsWith('cf:')) {
|
|
719
747
|
const key = s.field.slice(3)
|
|
720
748
|
const alias = sanitize(`cf:${key}`)
|
|
@@ -726,11 +754,9 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
726
754
|
cfSelectedAliases.push(alias)
|
|
727
755
|
}
|
|
728
756
|
}
|
|
729
|
-
q = q.orderBy(alias, (s.dir ?? 'asc') as any)
|
|
757
|
+
if (!requiresPlaintextSort) q = q.orderBy(alias, (s.dir ?? 'asc') as any)
|
|
730
758
|
} else {
|
|
731
|
-
|
|
732
|
-
if (!column) continue
|
|
733
|
-
q = q.orderBy(qualify(column), (s.dir ?? 'asc') as any)
|
|
759
|
+
if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), (s.dir ?? 'asc') as any)
|
|
734
760
|
}
|
|
735
761
|
}
|
|
736
762
|
|
|
@@ -747,7 +773,10 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
747
773
|
: q.clearSelect().clearOrderBy().select(sql<string>`count(distinct ${sql.ref(`${table}.id`)})`.as('count'))
|
|
748
774
|
const countRow = await countBuilder.executeTakeFirst() as { count: unknown } | undefined
|
|
749
775
|
const total = Number((countRow as any)?.count ?? 0)
|
|
750
|
-
const
|
|
776
|
+
const dataQuery = requiresPlaintextSort
|
|
777
|
+
? q
|
|
778
|
+
: q.limit(pageSize).offset((page - 1) * pageSize)
|
|
779
|
+
const items = await dataQuery.execute() as any[]
|
|
751
780
|
|
|
752
781
|
if (cfJsonAliases.size > 0) {
|
|
753
782
|
for (const row of items) {
|
|
@@ -771,7 +800,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
771
800
|
}
|
|
772
801
|
}
|
|
773
802
|
|
|
774
|
-
const svc =
|
|
803
|
+
const svc = encryptionService
|
|
775
804
|
const decryptPayload =
|
|
776
805
|
svc?.decryptEntityPayload?.bind(svc) as
|
|
777
806
|
| ((
|
|
@@ -783,9 +812,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
783
812
|
| null
|
|
784
813
|
let decryptedItems = items
|
|
785
814
|
if (decryptPayload) {
|
|
786
|
-
const fallbackOrgId =
|
|
787
|
-
opts.organizationId
|
|
788
|
-
?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
|
|
789
815
|
decryptedItems = await Promise.all(
|
|
790
816
|
(items as any[]).map(async (item) => {
|
|
791
817
|
try {
|
|
@@ -804,7 +830,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
804
830
|
)
|
|
805
831
|
}
|
|
806
832
|
|
|
807
|
-
|
|
833
|
+
const pagedItems = requiresPlaintextSort
|
|
834
|
+
? sortRowsInMemory(decryptedItems as Record<string, unknown>[], resolvedSorts)
|
|
835
|
+
.slice((page - 1) * pageSize, page * pageSize)
|
|
836
|
+
: decryptedItems
|
|
837
|
+
|
|
838
|
+
let queryResult: QueryResult<T> = { items: pagedItems, page, pageSize, total }
|
|
808
839
|
|
|
809
840
|
// --- UMES query extension: after-query pipeline ---
|
|
810
841
|
if (ext && extensionCtx) {
|