@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.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/crud/factory.js +3 -0
  3. package/dist/lib/crud/factory.js.map +2 -2
  4. package/dist/lib/encryption/decryptScope.js +51 -0
  5. package/dist/lib/encryption/decryptScope.js.map +7 -0
  6. package/dist/lib/encryption/find.js +24 -10
  7. package/dist/lib/encryption/find.js.map +2 -2
  8. package/dist/lib/encryption/subscriber.js +73 -15
  9. package/dist/lib/encryption/subscriber.js.map +2 -2
  10. package/dist/lib/query/encrypted-sort.js +15 -6
  11. package/dist/lib/query/encrypted-sort.js.map +2 -2
  12. package/dist/lib/query/engine.js +57 -10
  13. package/dist/lib/query/engine.js.map +2 -2
  14. package/dist/lib/query/types.js.map +1 -1
  15. package/dist/lib/version.js +1 -1
  16. package/dist/lib/version.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/lib/crud/__tests__/crud-factory.test.ts +16 -0
  19. package/src/lib/crud/factory.ts +13 -0
  20. package/src/lib/encryption/__tests__/decrypt-scope.test.ts +134 -0
  21. package/src/lib/encryption/__tests__/subscriber.onload-tenant-binding.test.ts +234 -0
  22. package/src/lib/encryption/__tests__/subscriber.refusal-aggregation.test.ts +105 -0
  23. package/src/lib/encryption/__tests__/subscriber.tenant-binding.test.ts +131 -0
  24. package/src/lib/encryption/decryptScope.ts +119 -0
  25. package/src/lib/encryption/find.ts +17 -12
  26. package/src/lib/encryption/subscriber.ts +101 -19
  27. package/src/lib/query/__tests__/engine-decrypt-refusal-log.test.ts +168 -0
  28. package/src/lib/query/__tests__/engine.test.ts +237 -0
  29. package/src/lib/query/encrypted-sort.ts +34 -8
  30. package/src/lib/query/engine.ts +70 -10
  31. 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
- export async function resolveEncryptedSortFields(
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<Set<string>> {
54
- if (!service?.getEncryptedFieldNames) return new Set()
55
- if (service.isEnabled && !service.isEnabled()) return new Set()
56
- const encrypted = await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)
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 encrypted) {
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]
@@ -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 { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
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 encryptionService = this.getEncryptionService()
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 encryptedSortFields = await resolveEncryptedSortFields(
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) {
@@ -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