@open-mercato/shared 0.10.1-develop.7679.1.cb2c13f49e → 0.10.1-develop.7687.1.e1a6f9de51
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/crud/factory.js +3 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/encryption/decryptScope.js +51 -0
- package/dist/lib/encryption/decryptScope.js.map +7 -0
- package/dist/lib/encryption/find.js +24 -10
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +73 -15
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/query/encrypted-sort.js +15 -6
- package/dist/lib/query/encrypted-sort.js.map +2 -2
- package/dist/lib/query/engine.js +57 -10
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +16 -0
- package/src/lib/crud/factory.ts +13 -0
- package/src/lib/encryption/__tests__/decrypt-scope.test.ts +134 -0
- package/src/lib/encryption/__tests__/subscriber.onload-tenant-binding.test.ts +234 -0
- package/src/lib/encryption/__tests__/subscriber.refusal-aggregation.test.ts +105 -0
- package/src/lib/encryption/__tests__/subscriber.tenant-binding.test.ts +131 -0
- package/src/lib/encryption/decryptScope.ts +119 -0
- package/src/lib/encryption/find.ts +17 -12
- package/src/lib/encryption/subscriber.ts +101 -19
- package/src/lib/query/__tests__/engine-decrypt-refusal-log.test.ts +168 -0
- package/src/lib/query/__tests__/engine.test.ts +237 -0
- package/src/lib/query/encrypted-sort.ts +34 -8
- package/src/lib/query/engine.ts +70 -10
- package/src/lib/query/types.ts +15 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const warnMock = jest.fn()
|
|
2
|
+
|
|
3
|
+
jest.mock('../../logger', () => {
|
|
4
|
+
const child = () => ({ debug: jest.fn(), info: jest.fn(), warn: warnMock, error: jest.fn(), child })
|
|
5
|
+
return { createLogger: () => ({ debug: jest.fn(), info: jest.fn(), warn: warnMock, error: jest.fn(), child }) }
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
import { BasicQueryEngine } from '../engine'
|
|
9
|
+
import { SortDir } from '../types'
|
|
10
|
+
import { registerModules } from '../../i18n/server'
|
|
11
|
+
import {
|
|
12
|
+
DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
|
|
13
|
+
DECRYPT_REFUSAL_LOG_MESSAGE,
|
|
14
|
+
} from '../../encryption/decryptScope'
|
|
15
|
+
|
|
16
|
+
registerModules([] as any)
|
|
17
|
+
|
|
18
|
+
type FakeRow = Record<string, any>
|
|
19
|
+
|
|
20
|
+
function createFakeDb(rows: FakeRow[]) {
|
|
21
|
+
const columns = ['id', 'tenant_id', 'organization_id', 'deleted_at', 'display_name'].map((column_name) => ({
|
|
22
|
+
table_name: 'customer_entities',
|
|
23
|
+
column_name,
|
|
24
|
+
}))
|
|
25
|
+
const build = (table: string) => {
|
|
26
|
+
const state: any = { table, rows: table === 'customer_entities' ? rows : columns }
|
|
27
|
+
const chain: any = new Proxy(
|
|
28
|
+
{},
|
|
29
|
+
{
|
|
30
|
+
get(_target, prop: string) {
|
|
31
|
+
if (prop === 'execute') return async () => state.rows.map((row: FakeRow) => ({ ...row }))
|
|
32
|
+
if (prop === 'executeTakeFirst') return async () => ({ count: String(state.rows.length) })
|
|
33
|
+
if (prop === 'then') return undefined
|
|
34
|
+
return () => chain
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
return chain
|
|
39
|
+
}
|
|
40
|
+
return { selectFrom: (table: string) => build(table), fn: { count: () => ({ as: () => ({}) }) } }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('BasicQueryEngine aggregated decrypt-refusal warning (#5430)', () => {
|
|
44
|
+
beforeEach(() => warnMock.mockClear())
|
|
45
|
+
|
|
46
|
+
const engineFor = (rows: FakeRow[]) =>
|
|
47
|
+
new BasicQueryEngine({} as any, () => createFakeDb(rows) as any, () => ({
|
|
48
|
+
isEnabled: () => true,
|
|
49
|
+
getEncryptedFieldNames: async () => ['display_name'],
|
|
50
|
+
decryptEntityPayload: async () => ({ display_name: 'PLAINTEXT' }),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const run = (rows: FakeRow[]) =>
|
|
54
|
+
engineFor(rows).query('customers:customer_entity', {
|
|
55
|
+
tenantId: 'tenant-a',
|
|
56
|
+
fields: ['id', 'display_name'],
|
|
57
|
+
page: { page: 1, pageSize: 10 },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const refusalWarnings = () => warnMock.mock.calls.filter(([message]) => message === DECRYPT_REFUSAL_LOG_MESSAGE)
|
|
61
|
+
|
|
62
|
+
test('emits exactly one warning for a page containing several refused rows', async () => {
|
|
63
|
+
await run([
|
|
64
|
+
{ id: '1', tenant_id: 'tenant-b', organization_id: 'org1', display_name: 'cipher-1' },
|
|
65
|
+
{ id: '2', tenant_id: 'tenant-c', organization_id: 'org1', display_name: 'cipher-2' },
|
|
66
|
+
{ id: '3', tenant_id: 'tenant-d', organization_id: 'org1', display_name: 'cipher-3' },
|
|
67
|
+
])
|
|
68
|
+
const warnings = refusalWarnings()
|
|
69
|
+
expect(warnings).toHaveLength(1)
|
|
70
|
+
expect(warnings[0][1]).toEqual({
|
|
71
|
+
entity: 'customers:customer_entity',
|
|
72
|
+
refusedRows: 3,
|
|
73
|
+
callerTenantId: 'tenant-a',
|
|
74
|
+
rowTenantIds: ['tenant-b', 'tenant-c', 'tenant-d'],
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('samples at most three distinct row tenant ids while counting them all', async () => {
|
|
79
|
+
await run(
|
|
80
|
+
['tenant-b', 'tenant-c', 'tenant-d', 'tenant-e', 'tenant-f'].map((tenant, index) => ({
|
|
81
|
+
id: String(index + 1),
|
|
82
|
+
tenant_id: tenant,
|
|
83
|
+
organization_id: 'org1',
|
|
84
|
+
display_name: `cipher-${index}`,
|
|
85
|
+
})),
|
|
86
|
+
)
|
|
87
|
+
const [, context] = refusalWarnings()[0]
|
|
88
|
+
expect(context.refusedRows).toBe(5)
|
|
89
|
+
expect(context.rowTenantIds).toHaveLength(3)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('emits no warning when every row matches the caller tenant', async () => {
|
|
93
|
+
await run([
|
|
94
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
95
|
+
{ id: '2', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-2' },
|
|
96
|
+
])
|
|
97
|
+
expect(refusalWarnings()).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// The plaintext-sort path decrypts the candidate scan and then the page rows, so an unkeyed
|
|
101
|
+
// counter tallied every surviving row twice and the count the docs promise was wrong.
|
|
102
|
+
test('counts a refused row once even though the plaintext-sort path decrypts it twice', async () => {
|
|
103
|
+
const rows = ['tenant-b', 'tenant-c', 'tenant-d'].map((tenant, index) => ({
|
|
104
|
+
id: String(index + 1),
|
|
105
|
+
tenant_id: tenant,
|
|
106
|
+
organization_id: 'org1',
|
|
107
|
+
display_name: `cipher-${index}`,
|
|
108
|
+
}))
|
|
109
|
+
await engineFor(rows).query('customers:customer_entity', {
|
|
110
|
+
tenantId: 'tenant-a',
|
|
111
|
+
fields: ['id', 'display_name'],
|
|
112
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
113
|
+
page: { page: 1, pageSize: 10 },
|
|
114
|
+
})
|
|
115
|
+
const warnings = refusalWarnings()
|
|
116
|
+
expect(warnings).toHaveLength(1)
|
|
117
|
+
expect(warnings[0][1]).toMatchObject({ refusedRows: 3 })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('warns once when a declined query sorts on an encrypted field', async () => {
|
|
121
|
+
await engineFor([
|
|
122
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
123
|
+
]).query('customers:customer_entity', {
|
|
124
|
+
tenantId: 'tenant-a',
|
|
125
|
+
decryptEncryptedFields: false,
|
|
126
|
+
fields: ['id', 'display_name'],
|
|
127
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
128
|
+
page: { page: 1, pageSize: 10 },
|
|
129
|
+
})
|
|
130
|
+
const warnings = warnMock.mock.calls.filter(([message]) => message === DECLINED_ENCRYPTED_SORT_LOG_MESSAGE)
|
|
131
|
+
expect(warnings).toHaveLength(1)
|
|
132
|
+
expect(warnings[0][1]).toEqual({
|
|
133
|
+
entity: 'customers:customer_entity',
|
|
134
|
+
sortFields: ['display_name'],
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('does not warn when a declined query sorts on a plaintext column', async () => {
|
|
139
|
+
await engineFor([
|
|
140
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
141
|
+
]).query('customers:customer_entity', {
|
|
142
|
+
tenantId: 'tenant-a',
|
|
143
|
+
decryptEncryptedFields: false,
|
|
144
|
+
fields: ['id', 'display_name'],
|
|
145
|
+
sort: [{ field: 'id', dir: SortDir.Asc }],
|
|
146
|
+
page: { page: 1, pageSize: 10 },
|
|
147
|
+
})
|
|
148
|
+
expect(warnMock.mock.calls.filter(([message]) => message === DECLINED_ENCRYPTED_SORT_LOG_MESSAGE)).toHaveLength(0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('does not warn about an encrypted sort when the query decrypts normally', async () => {
|
|
152
|
+
await engineFor([
|
|
153
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
154
|
+
]).query('customers:customer_entity', {
|
|
155
|
+
tenantId: 'tenant-a',
|
|
156
|
+
fields: ['id', 'display_name'],
|
|
157
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
158
|
+
page: { page: 1, pageSize: 10 },
|
|
159
|
+
})
|
|
160
|
+
expect(warnMock.mock.calls.filter(([message]) => message === DECLINED_ENCRYPTED_SORT_LOG_MESSAGE)).toHaveLength(0)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('never puts a decrypted or ciphertext field value in the log context', async () => {
|
|
164
|
+
await run([{ id: '1', tenant_id: 'tenant-b', organization_id: 'org1', display_name: 'cipher-secret' }])
|
|
165
|
+
expect(JSON.stringify(refusalWarnings()[0][1])).not.toContain('cipher-secret')
|
|
166
|
+
expect(JSON.stringify(refusalWarnings()[0][1])).not.toContain('PLAINTEXT')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -1803,3 +1803,240 @@ describe('module-scoped column-existence cache (#5605)', () => {
|
|
|
1803
1803
|
expect(columnProbesFor(fakeDb, 'tenant_id').length).toBeGreaterThan(probesAfterFirstEngine)
|
|
1804
1804
|
})
|
|
1805
1805
|
})
|
|
1806
|
+
|
|
1807
|
+
describe('BasicQueryEngine decrypt tenant binding (#5430)', () => {
|
|
1808
|
+
const informationSchema = [
|
|
1809
|
+
{ table_name: 'customer_entities', column_name: 'id' },
|
|
1810
|
+
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
1811
|
+
{ table_name: 'customer_entities', column_name: 'organization_id' },
|
|
1812
|
+
{ table_name: 'customer_entities', column_name: 'deleted_at' },
|
|
1813
|
+
{ table_name: 'customer_entities', column_name: 'display_name' },
|
|
1814
|
+
]
|
|
1815
|
+
|
|
1816
|
+
function setup(rows: any[], encryptedFieldNames: string[] = ['display_name']) {
|
|
1817
|
+
const fakeDb = createFakeKysely({ customer_entities: rows, 'information_schema.columns': informationSchema })
|
|
1818
|
+
const decryptCalls: Array<{ tenantId: string | null; organizationId: string | null }> = []
|
|
1819
|
+
const engine = new BasicQueryEngine(
|
|
1820
|
+
{} as any,
|
|
1821
|
+
() => fakeDb as any,
|
|
1822
|
+
() => ({
|
|
1823
|
+
isEnabled: () => true,
|
|
1824
|
+
getEncryptedFieldNames: async () => encryptedFieldNames,
|
|
1825
|
+
decryptEntityPayload: async (_entityId: any, _payload: any, tenantId: any, organizationId: any) => {
|
|
1826
|
+
decryptCalls.push({ tenantId, organizationId })
|
|
1827
|
+
return { display_name: 'PLAINTEXT' }
|
|
1828
|
+
},
|
|
1829
|
+
}),
|
|
1830
|
+
)
|
|
1831
|
+
return { engine, decryptCalls, fakeDb }
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Every column named in any SELECT issued against the base table.
|
|
1835
|
+
const selectedBaseColumns = (fakeDb: any): string[] =>
|
|
1836
|
+
fakeDb._calls
|
|
1837
|
+
.filter((call: any) => call._ops.table === 'customer_entities')
|
|
1838
|
+
.flatMap((call: any) => selectAliases(call))
|
|
1839
|
+
|
|
1840
|
+
test('returns ciphertext and never fetches a DEK when the row tenant contradicts the query tenant', async () => {
|
|
1841
|
+
const { engine, decryptCalls } = setup([
|
|
1842
|
+
{ id: '1', tenant_id: 'tenant-b', organization_id: 'org1', display_name: 'cipher-1' },
|
|
1843
|
+
])
|
|
1844
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1845
|
+
tenantId: 'tenant-a',
|
|
1846
|
+
fields: ['id', 'display_name'],
|
|
1847
|
+
page: { page: 1, pageSize: 10 },
|
|
1848
|
+
})
|
|
1849
|
+
expect(decryptCalls).toEqual([])
|
|
1850
|
+
expect((result.items[0] as any).display_name).toBe('cipher-1')
|
|
1851
|
+
})
|
|
1852
|
+
|
|
1853
|
+
test('still decrypts a row whose tenant matches the query tenant', async () => {
|
|
1854
|
+
const { engine, decryptCalls } = setup([
|
|
1855
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
1856
|
+
])
|
|
1857
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1858
|
+
tenantId: 'tenant-a',
|
|
1859
|
+
fields: ['id', 'display_name'],
|
|
1860
|
+
page: { page: 1, pageSize: 10 },
|
|
1861
|
+
})
|
|
1862
|
+
expect(decryptCalls).toEqual([{ tenantId: 'tenant-a', organizationId: 'org1' }])
|
|
1863
|
+
expect((result.items[0] as any).display_name).toBe('PLAINTEXT')
|
|
1864
|
+
})
|
|
1865
|
+
|
|
1866
|
+
// Both engines require `opts.tenantId` on every query, so a row that carries no tenant of its
|
|
1867
|
+
// own — a global row on an `omitAutomaticTenantOrgScope` read — is the only fallback case left.
|
|
1868
|
+
test('a row with no tenant of its own still decrypts under the caller tenant', async () => {
|
|
1869
|
+
const { engine, decryptCalls } = setup([
|
|
1870
|
+
{ id: '1', tenant_id: null, organization_id: null, display_name: 'cipher-1' },
|
|
1871
|
+
])
|
|
1872
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1873
|
+
tenantId: 'tenant-a',
|
|
1874
|
+
omitAutomaticTenantOrgScope: true,
|
|
1875
|
+
fields: ['id', 'display_name'],
|
|
1876
|
+
page: { page: 1, pageSize: 10 },
|
|
1877
|
+
})
|
|
1878
|
+
expect(decryptCalls).toEqual([{ tenantId: 'tenant-a', organizationId: null }])
|
|
1879
|
+
expect((result.items[0] as any).display_name).toBe('PLAINTEXT')
|
|
1880
|
+
})
|
|
1881
|
+
|
|
1882
|
+
test('performs no DEK lookup at all when the query declines decryption', async () => {
|
|
1883
|
+
const { engine, decryptCalls } = setup([
|
|
1884
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
1885
|
+
])
|
|
1886
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1887
|
+
tenantId: 'tenant-a',
|
|
1888
|
+
decryptEncryptedFields: false,
|
|
1889
|
+
fields: ['id', 'display_name'],
|
|
1890
|
+
page: { page: 1, pageSize: 10 },
|
|
1891
|
+
})
|
|
1892
|
+
expect(decryptCalls).toEqual([])
|
|
1893
|
+
expect((result.items[0] as any).display_name).toBe('cipher-1')
|
|
1894
|
+
})
|
|
1895
|
+
|
|
1896
|
+
test('an explicit true keeps decrypting, as does omitting the flag', async () => {
|
|
1897
|
+
for (const decryptEncryptedFields of [true, undefined]) {
|
|
1898
|
+
const { engine, decryptCalls } = setup([
|
|
1899
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
1900
|
+
])
|
|
1901
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1902
|
+
tenantId: 'tenant-a',
|
|
1903
|
+
decryptEncryptedFields,
|
|
1904
|
+
fields: ['id', 'display_name'],
|
|
1905
|
+
page: { page: 1, pageSize: 10 },
|
|
1906
|
+
})
|
|
1907
|
+
expect(decryptCalls).toHaveLength(1)
|
|
1908
|
+
expect((result.items[0] as any).display_name).toBe('PLAINTEXT')
|
|
1909
|
+
}
|
|
1910
|
+
})
|
|
1911
|
+
|
|
1912
|
+
// A declined query must not take the plaintext-sort path: that path exists only to sort on
|
|
1913
|
+
// decrypted values, so running it without decryption scans the full candidate set and then
|
|
1914
|
+
// orders by ciphertext. It reads the encryption MAP once — not a DEK — only to warn that the
|
|
1915
|
+
// requested sort has degraded to ciphertext ordering.
|
|
1916
|
+
test('a declined query resolves no encrypted sort fields and never scans for a plaintext sort', async () => {
|
|
1917
|
+
const fakeDb = createFakeKysely({
|
|
1918
|
+
customer_entities: [
|
|
1919
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-b' },
|
|
1920
|
+
{ id: '2', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-a' },
|
|
1921
|
+
],
|
|
1922
|
+
'information_schema.columns': informationSchema,
|
|
1923
|
+
})
|
|
1924
|
+
const decryptCalls: string[] = []
|
|
1925
|
+
const engine = new BasicQueryEngine(
|
|
1926
|
+
{} as any,
|
|
1927
|
+
() => fakeDb as any,
|
|
1928
|
+
() => ({
|
|
1929
|
+
isEnabled: () => true,
|
|
1930
|
+
getEncryptedFieldNames: async () => ['display_name'],
|
|
1931
|
+
decryptEntityPayload: async () => {
|
|
1932
|
+
decryptCalls.push('called')
|
|
1933
|
+
return { display_name: 'PLAINTEXT' }
|
|
1934
|
+
},
|
|
1935
|
+
}),
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1939
|
+
tenantId: 'tenant-a',
|
|
1940
|
+
decryptEncryptedFields: false,
|
|
1941
|
+
fields: ['id', 'display_name'],
|
|
1942
|
+
sort: [{ field: 'display_name', dir: SortDir.Asc }],
|
|
1943
|
+
page: { page: 1, pageSize: 10 },
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
expect(decryptCalls).toEqual([])
|
|
1947
|
+
expect((result.items[0] as any).display_name).toBe('cipher-b')
|
|
1948
|
+
const baseCalls = fakeDb._calls.filter((call: any) => call._ops.table === 'customer_entities')
|
|
1949
|
+
// The plaintext-sort path issues a second (phase-1 candidate) query with no ORDER BY;
|
|
1950
|
+
// a declined query must stay on the single ordinary SQL-ordered read.
|
|
1951
|
+
expect(baseCalls.some((call: any) => call._ops.orderBys?.length > 0)).toBe(true)
|
|
1952
|
+
})
|
|
1953
|
+
|
|
1954
|
+
test('an omitAutomaticTenantOrgScope query still decrypts by default', async () => {
|
|
1955
|
+
const { engine, decryptCalls } = setup([
|
|
1956
|
+
{ id: '1', tenant_id: null, organization_id: null, display_name: 'cipher-1' },
|
|
1957
|
+
])
|
|
1958
|
+
await engine.query('customers:customer_entity', {
|
|
1959
|
+
tenantId: 'tenant-a',
|
|
1960
|
+
omitAutomaticTenantOrgScope: true,
|
|
1961
|
+
fields: ['id', 'display_name'],
|
|
1962
|
+
page: { page: 1, pageSize: 10 },
|
|
1963
|
+
})
|
|
1964
|
+
expect(decryptCalls).toHaveLength(1)
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1967
|
+
// The decision reads the row's own tenant_id, so a `fields` list that omits it would leave
|
|
1968
|
+
// `rowTenantId` null and make the guard inert on exactly the routes that project narrowly
|
|
1969
|
+
// (e.g. the deals map route). The engine widens the projection and strips the column back out.
|
|
1970
|
+
describe('tenant_id is carried into the decrypt decision regardless of the projection', () => {
|
|
1971
|
+
test('a projection without tenant_id still refuses a foreign-tenant row', async () => {
|
|
1972
|
+
const { engine, decryptCalls, fakeDb } = setup([
|
|
1973
|
+
{ id: '1', tenant_id: 'tenant-b', organization_id: 'org1', display_name: 'cipher-1' },
|
|
1974
|
+
])
|
|
1975
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1976
|
+
tenantId: 'tenant-a',
|
|
1977
|
+
fields: ['id', 'organization_id', 'display_name'],
|
|
1978
|
+
page: { page: 1, pageSize: 10 },
|
|
1979
|
+
})
|
|
1980
|
+
expect(selectedBaseColumns(fakeDb)).toContain('tenant_id')
|
|
1981
|
+
expect(decryptCalls).toEqual([])
|
|
1982
|
+
expect((result.items[0] as any).display_name).toBe('cipher-1')
|
|
1983
|
+
})
|
|
1984
|
+
|
|
1985
|
+
test('the injected column is stripped from the response shape', async () => {
|
|
1986
|
+
const { engine, fakeDb } = setup([
|
|
1987
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
1988
|
+
])
|
|
1989
|
+
const result = await engine.query('customers:customer_entity', {
|
|
1990
|
+
tenantId: 'tenant-a',
|
|
1991
|
+
fields: ['id', 'organization_id', 'display_name'],
|
|
1992
|
+
page: { page: 1, pageSize: 10 },
|
|
1993
|
+
})
|
|
1994
|
+
expect(selectedBaseColumns(fakeDb)).toContain('tenant_id')
|
|
1995
|
+
expect(Object.prototype.hasOwnProperty.call(result.items[0], 'tenant_id')).toBe(false)
|
|
1996
|
+
expect((result.items[0] as any).display_name).toBe('PLAINTEXT')
|
|
1997
|
+
})
|
|
1998
|
+
|
|
1999
|
+
test('a caller that asked for tenant_id still gets it back', async () => {
|
|
2000
|
+
const { engine } = setup([
|
|
2001
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
2002
|
+
])
|
|
2003
|
+
const result = await engine.query('customers:customer_entity', {
|
|
2004
|
+
tenantId: 'tenant-a',
|
|
2005
|
+
fields: ['id', 'tenant_id', 'display_name'],
|
|
2006
|
+
page: { page: 1, pageSize: 10 },
|
|
2007
|
+
})
|
|
2008
|
+
expect((result.items[0] as any).tenant_id).toBe('tenant-a')
|
|
2009
|
+
})
|
|
2010
|
+
|
|
2011
|
+
test('an entity with no encryption map keeps its projection untouched', async () => {
|
|
2012
|
+
const { engine, fakeDb } = setup(
|
|
2013
|
+
[{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'plain-1' }],
|
|
2014
|
+
[],
|
|
2015
|
+
)
|
|
2016
|
+
await engine.query('customers:customer_entity', {
|
|
2017
|
+
tenantId: 'tenant-a',
|
|
2018
|
+
fields: ['id', 'display_name'],
|
|
2019
|
+
page: { page: 1, pageSize: 10 },
|
|
2020
|
+
})
|
|
2021
|
+
expect(selectedBaseColumns(fakeDb)).not.toContain('tenant_id')
|
|
2022
|
+
})
|
|
2023
|
+
})
|
|
2024
|
+
|
|
2025
|
+
test('refuses each mismatching row independently within one page', async () => {
|
|
2026
|
+
const { engine, decryptCalls } = setup([
|
|
2027
|
+
{ id: '1', tenant_id: 'tenant-a', organization_id: 'org1', display_name: 'cipher-1' },
|
|
2028
|
+
{ id: '2', tenant_id: 'tenant-b', organization_id: 'org1', display_name: 'cipher-2' },
|
|
2029
|
+
{ id: '3', tenant_id: 'tenant-c', organization_id: 'org1', display_name: 'cipher-3' },
|
|
2030
|
+
])
|
|
2031
|
+
const result = await engine.query('customers:customer_entity', {
|
|
2032
|
+
tenantId: 'tenant-a',
|
|
2033
|
+
fields: ['id', 'display_name'],
|
|
2034
|
+
page: { page: 1, pageSize: 10 },
|
|
2035
|
+
})
|
|
2036
|
+
expect(decryptCalls).toHaveLength(1)
|
|
2037
|
+
const byId = new Map((result.items as any[]).map((row) => [row.id, row.display_name]))
|
|
2038
|
+
expect(byId.get('1')).toBe('PLAINTEXT')
|
|
2039
|
+
expect(byId.get('2')).toBe('cipher-2')
|
|
2040
|
+
expect(byId.get('3')).toBe('cipher-3')
|
|
2041
|
+
})
|
|
2042
|
+
})
|
|
@@ -44,21 +44,36 @@ export function resolveEncryptedSortMaxRows(): number | null {
|
|
|
44
44
|
return Math.floor(parsed)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Reads the entity's encryption map once so a query can answer both "which sort fields are
|
|
49
|
+
* encrypted" and "must the projection carry `tenant_id` into the decrypt decision" without paying
|
|
50
|
+
* for two lookups.
|
|
51
|
+
*
|
|
52
|
+
* `null` means the map is unavailable — no service, or one that cannot report field names. A query
|
|
53
|
+
* that reads `null` keeps today's behaviour for sorting (no plaintext-sort path) but must assume a
|
|
54
|
+
* decrypt attempt may still follow. An empty array means the entity genuinely encrypts nothing.
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveEncryptedFieldNames(
|
|
48
57
|
service: QueryEncryptionService | null | undefined,
|
|
49
58
|
entity: EntityId,
|
|
50
|
-
sortFields: readonly string[],
|
|
51
59
|
tenantId?: string | null,
|
|
52
60
|
organizationId?: string | null,
|
|
53
|
-
): Promise<
|
|
54
|
-
if (!service
|
|
55
|
-
if (
|
|
56
|
-
|
|
61
|
+
): Promise<readonly string[] | null> {
|
|
62
|
+
if (service?.isEnabled && !service.isEnabled()) return []
|
|
63
|
+
if (!service?.getEncryptedFieldNames) return null
|
|
64
|
+
return await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function matchEncryptedSortFields(
|
|
68
|
+
encryptedFieldNames: readonly string[] | null,
|
|
69
|
+
sortFields: readonly string[],
|
|
70
|
+
): Set<string> {
|
|
71
|
+
const result = new Set<string>()
|
|
72
|
+
if (!encryptedFieldNames || encryptedFieldNames.length === 0) return result
|
|
57
73
|
const encryptedCandidates = new Set<string>()
|
|
58
|
-
for (const field of
|
|
74
|
+
for (const field of encryptedFieldNames) {
|
|
59
75
|
for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)
|
|
60
76
|
}
|
|
61
|
-
const result = new Set<string>()
|
|
62
77
|
for (const field of sortFields) {
|
|
63
78
|
if (field.startsWith('cf:')) continue
|
|
64
79
|
const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))
|
|
@@ -67,6 +82,17 @@ export async function resolveEncryptedSortFields(
|
|
|
67
82
|
return result
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
export async function resolveEncryptedSortFields(
|
|
86
|
+
service: QueryEncryptionService | null | undefined,
|
|
87
|
+
entity: EntityId,
|
|
88
|
+
sortFields: readonly string[],
|
|
89
|
+
tenantId?: string | null,
|
|
90
|
+
organizationId?: string | null,
|
|
91
|
+
): Promise<Set<string>> {
|
|
92
|
+
const encryptedFieldNames = await resolveEncryptedFieldNames(service, entity, tenantId, organizationId)
|
|
93
|
+
return matchEncryptedSortFields(encryptedFieldNames, sortFields)
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
function readField(row: Record<string, unknown>, field: string): unknown {
|
|
71
97
|
for (const candidate of fieldNameCandidates(field)) {
|
|
72
98
|
if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -33,9 +33,22 @@ import {
|
|
|
33
33
|
type ResolvedCustomFieldDefinitions,
|
|
34
34
|
} from '../crud/custom-field-definition-index'
|
|
35
35
|
import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
matchEncryptedSortFields,
|
|
38
|
+
resolveEncryptedFieldNames,
|
|
39
|
+
resolveEncryptedSortFields,
|
|
40
|
+
resolveEncryptedSortMaxRows,
|
|
41
|
+
sortRowsInMemory,
|
|
42
|
+
} from './encrypted-sort'
|
|
37
43
|
import { resolveListCountCap } from './count-cap'
|
|
38
44
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
45
|
+
import {
|
|
46
|
+
DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
|
|
47
|
+
DECRYPT_REFUSAL_LOG_MESSAGE,
|
|
48
|
+
DecryptRefusalTally,
|
|
49
|
+
resolveDecryptEnabled,
|
|
50
|
+
resolveDecryptScope,
|
|
51
|
+
} from '../encryption/decryptScope'
|
|
39
52
|
import { parseNumberWithDefault } from '../number'
|
|
40
53
|
import { createLogger } from '../logger'
|
|
41
54
|
import {
|
|
@@ -726,7 +739,8 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
726
739
|
const fallbackOrgId =
|
|
727
740
|
opts.organizationId
|
|
728
741
|
?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
|
|
729
|
-
const
|
|
742
|
+
const decryptEnabled = resolveDecryptEnabled(opts)
|
|
743
|
+
const encryptionService = decryptEnabled ? this.getEncryptionService() : null
|
|
730
744
|
const resolvedSorts: Sort[] = []
|
|
731
745
|
for (const s of opts.sort || []) {
|
|
732
746
|
if (s.field.startsWith('cf:')) {
|
|
@@ -736,14 +750,41 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
736
750
|
if (column) resolvedSorts.push({ ...s, field: column })
|
|
737
751
|
}
|
|
738
752
|
}
|
|
739
|
-
const
|
|
753
|
+
const plainSortFields = resolvedSorts
|
|
754
|
+
.filter((sort) => !sort.field.startsWith('cf:'))
|
|
755
|
+
.map((sort) => sort.field)
|
|
756
|
+
const encryptedFieldNames = await resolveEncryptedFieldNames(
|
|
740
757
|
encryptionService,
|
|
741
758
|
entity,
|
|
742
|
-
resolvedSorts.filter((sort) => !sort.field.startsWith('cf:')).map((sort) => sort.field),
|
|
743
759
|
opts.tenantId ?? null,
|
|
744
760
|
fallbackOrgId,
|
|
745
761
|
)
|
|
762
|
+
const encryptedSortFields = matchEncryptedSortFields(encryptedFieldNames, plainSortFields)
|
|
746
763
|
const requiresPlaintextSort = encryptedSortFields.size > 0
|
|
764
|
+
if (!decryptEnabled && plainSortFields.length > 0) {
|
|
765
|
+
const declinedSortFields = await resolveEncryptedSortFields(
|
|
766
|
+
this.getEncryptionService(),
|
|
767
|
+
entity,
|
|
768
|
+
plainSortFields,
|
|
769
|
+
opts.tenantId ?? null,
|
|
770
|
+
fallbackOrgId,
|
|
771
|
+
)
|
|
772
|
+
if (declinedSortFields.size > 0) {
|
|
773
|
+
logger.warn(DECLINED_ENCRYPTED_SORT_LOG_MESSAGE, {
|
|
774
|
+
entity: String(entity),
|
|
775
|
+
sortFields: Array.from(declinedSortFields),
|
|
776
|
+
})
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const requestedFields = opts.fields && opts.fields.length ? opts.fields.map(String) : null
|
|
781
|
+
const injectsTenantIdForDecrypt =
|
|
782
|
+
encryptionService != null
|
|
783
|
+
&& requestedFields != null
|
|
784
|
+
&& !requestedFields.includes('tenant_id')
|
|
785
|
+
&& !requestedFields.includes('tenantId')
|
|
786
|
+
&& (encryptedFieldNames === null || encryptedFieldNames.length > 0)
|
|
787
|
+
&& await this.columnExists(table, 'tenant_id')
|
|
747
788
|
|
|
748
789
|
const tenantId = opts.tenantId
|
|
749
790
|
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
@@ -880,6 +921,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
880
921
|
if (requiresPlaintextSort) {
|
|
881
922
|
for (const field of encryptedSortFields) cols.add(field)
|
|
882
923
|
}
|
|
924
|
+
if (injectsTenantIdForDecrypt) cols.add('tenant_id')
|
|
883
925
|
for (const c of cols) {
|
|
884
926
|
// Qualify and alias to base names to avoid ambiguity
|
|
885
927
|
q = q.select(sql.ref(qualify(c)).as(c))
|
|
@@ -1443,15 +1485,22 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1443
1485
|
) => Promise<Record<string, unknown>>)
|
|
1444
1486
|
| null
|
|
1445
1487
|
|
|
1488
|
+
const refusalTally = new DecryptRefusalTally()
|
|
1489
|
+
|
|
1446
1490
|
const decryptRow = async (item: ResultRow): Promise<ResultRow> => {
|
|
1447
1491
|
if (!decryptPayload) return item
|
|
1492
|
+
const decision = resolveDecryptScope({
|
|
1493
|
+
rowTenantId: (item?.tenant_id ?? item?.tenantId ?? null) as string | null,
|
|
1494
|
+
rowOrganizationId: (item?.organization_id ?? item?.organizationId ?? null) as string | null,
|
|
1495
|
+
callerTenantId: opts.tenantId ?? null,
|
|
1496
|
+
callerOrganizationId: fallbackOrgId ?? null,
|
|
1497
|
+
})
|
|
1498
|
+
if (!decision.decrypt) {
|
|
1499
|
+
refusalTally.record(decision, item?.id == null ? null : String(item.id))
|
|
1500
|
+
return item
|
|
1501
|
+
}
|
|
1448
1502
|
try {
|
|
1449
|
-
const decrypted = await decryptPayload(
|
|
1450
|
-
entity,
|
|
1451
|
-
item,
|
|
1452
|
-
(item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null) as string | null,
|
|
1453
|
-
(item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null) as string | null,
|
|
1454
|
-
)
|
|
1503
|
+
const decrypted = await decryptPayload(entity, item, decision.tenantId, decision.organizationId)
|
|
1455
1504
|
return { ...item, ...decrypted }
|
|
1456
1505
|
} catch (err) {
|
|
1457
1506
|
logger.error('Error decrypting entity payload', { err })
|
|
@@ -1459,6 +1508,11 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1459
1508
|
}
|
|
1460
1509
|
}
|
|
1461
1510
|
|
|
1511
|
+
const reportDecryptRefusals = () => {
|
|
1512
|
+
if (refusalTally.refused === 0) return
|
|
1513
|
+
logger.warn(DECRYPT_REFUSAL_LOG_MESSAGE, refusalTally.toLogContext(String(entity)))
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1462
1516
|
const normalizeCfJsonAliases = (rows: ResultRow[]) => {
|
|
1463
1517
|
if (cfJsonAliases.size === 0) return
|
|
1464
1518
|
for (const row of rows) {
|
|
@@ -1556,6 +1610,12 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1556
1610
|
: items
|
|
1557
1611
|
}
|
|
1558
1612
|
|
|
1613
|
+
reportDecryptRefusals()
|
|
1614
|
+
|
|
1615
|
+
if (injectsTenantIdForDecrypt) {
|
|
1616
|
+
for (const row of pagedItems) delete row.tenant_id
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1559
1619
|
let queryResult: QueryResult<T> = { items: pagedItems as unknown as T[], page, pageSize, total }
|
|
1560
1620
|
|
|
1561
1621
|
if (encryptedSortRowCapWarning || listCountCapWarning) {
|
package/src/lib/query/types.ts
CHANGED
|
@@ -126,6 +126,21 @@ export type QueryOptions = {
|
|
|
126
126
|
* not match the standard `organization_id = X AND tenant_id = Y` shape.
|
|
127
127
|
*/
|
|
128
128
|
omitAutomaticTenantOrgScope?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* When `false`, the engine returns encrypted fields as stored (ciphertext) instead of decrypting
|
|
131
|
+
* them, and performs no DEK lookup at all. Defaults to `true` — today's behaviour — so existing
|
|
132
|
+
* callers are unaffected.
|
|
133
|
+
*
|
|
134
|
+
* Use it on generic, entity-agnostic surfaces that select every column and do not need plaintext.
|
|
135
|
+
* Opting in to decline decryption is not an access control: when decryption DOES run it is bound
|
|
136
|
+
* to the caller's tenant by `resolveDecryptScope`, which refuses any row whose `tenant_id`
|
|
137
|
+
* contradicts `tenantId`.
|
|
138
|
+
*
|
|
139
|
+
* Caveat: declining also disables the plaintext-sort path, so a `sort` on an encrypted field
|
|
140
|
+
* degrades to SQL `ORDER BY` over ciphertext — a meaningless order. Both engines log one warning
|
|
141
|
+
* when that combination is requested.
|
|
142
|
+
*/
|
|
143
|
+
decryptEncryptedFields?: boolean
|
|
129
144
|
// Soft-delete behavior: when false (default), rows with non-null deleted_at
|
|
130
145
|
// are excluded if the base table has that column. Set true to include them.
|
|
131
146
|
withDeleted?: boolean
|