@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/query/types.ts"],
4
- "sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { Profiler } from '../profiler'\nimport type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'\n\n/**\n * `overlap` (additive, 2026-10-05): \"has any of\" over a multi-valued field. An index-document\n * string array compiles to jsonb `?|`, a `cf:*` key to any-value-in-set, and an array-typed base\n * column to `&&`. An empty value list matches nothing.\n *\n * `noverlap` (additive, 2026-10-05): \"has none of\" \u2014 the negation of `overlap` on an index-document\n * string array or an array-typed base column. Only a present array can match (a missing/null key or\n * NULL column never does); an empty value list excludes nothing. Unsupported on `cf:*` keys, where\n * it compiles to `false`.\n */\nexport type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists' | 'overlap' | 'noverlap'\n\nexport enum SortDir {\n Asc = 'asc',\n Desc = 'desc',\n}\n\nexport type FieldSelector = string // base field or custom field key (prefixed with 'cf:')\n\nexport type Filter = {\n field: FieldSelector\n op: FilterOp\n value?: any\n}\n\nexport type Sort = { field: FieldSelector; dir?: SortDir }\n\nexport type Page = { page?: number; pageSize?: number }\n\n// Mongo/Medusa-style filter operators (typed)\nexport type WhereOps<T> = {\n $eq?: T\n $ne?: T | null\n $gt?: T extends number | Date ? T : never\n $gte?: T extends number | Date ? T : never\n $lt?: T extends number | Date ? T : never\n $lte?: T extends number | Date ? T : never\n $in?: T[]\n $nin?: T[]\n $like?: T extends string ? string : never\n $ilike?: T extends string ? string : never\n $exists?: boolean\n $overlap?: T extends readonly (infer Element)[] ? Element[] : T[]\n $noverlap?: T extends readonly (infer Element)[] ? Element[] : T[]\n}\n\n// A field filter can be a direct value (equals) or ops object\nexport type WhereValue<T = any> = T | WhereOps<T>\n\n// Generic shape for object filters. If you have a typed map of field\u2192type,\n// pass it as the generic to get end-to-end typing.\n// Example: Where<{\n// id: string; title: string; created_at: Date; 'cf:severity': number\n// }>\nexport type Where<Fields extends Record<string, any> = Record<string, any>> =\n Partial<{ [K in keyof Fields]: WhereValue<Fields[K]> }> & Record<string, WhereValue>\n\nexport type QueryCustomFieldJoin = {\n fromField: string\n toField: string\n type?: 'left' | 'inner'\n}\n\nexport type QueryCustomFieldSource = {\n entityId: EntityId\n table?: string\n alias?: string\n recordIdColumn?: string\n join?: QueryCustomFieldJoin\n tenantField?: string\n organizationField?: string\n}\n\nexport type QueryJoinEdge = {\n alias: string\n table?: string\n entityId?: EntityId\n from: {\n alias?: string\n field: string\n }\n to: {\n field: string\n }\n type?: 'left' | 'inner'\n}\n\n/**\n * Optional context for query-level UMES extensions.\n * When provided, the query engine will execute sync lifecycle events\n * (querying/queried) and apply query-enabled enrichers.\n */\nexport type QueryExtensionsConfig = {\n userId?: string\n container?: unknown\n userFeatures?: string[]\n resolve?: <T = unknown>(name: string) => T\n}\n\nexport type QueryOptions = {\n fields?: FieldSelector[] // base fields and/or 'cf:<key>' for custom fields\n includeExtensions?: boolean | string[] // include all registered extensions or only specific ones by entity id\n includeCustomFields?: boolean | string[] // include all CFs or specific keys\n // Accept classic array syntax or Mongo-style object syntax\n filters?: Filter[] | Where\n sort?: Sort[]\n page?: Page\n organizationId?: string // enforce multi-tenant scope\n tenantId?: string // enforce tenant scope\n // Optional list of organization ids to scope results. Takes precedence over organizationId.\n organizationIds?: string[]\n /**\n * When true, the engine does not apply default `organization_id` / `tenant_id` equality guards.\n *\n * Callers MUST encode full visibility in `filters` (for example with `$or` of scoped branches)\n * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise\n * queries return cross-tenant rows.\n *\n * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine\n * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read\n * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does\n * not match the standard `organization_id = X AND tenant_id = Y` shape.\n */\n omitAutomaticTenantOrgScope?: boolean\n // Soft-delete behavior: when false (default), rows with non-null deleted_at\n // are excluded if the base table has that column. Set true to include them.\n withDeleted?: boolean\n customFieldSources?: QueryCustomFieldSource[]\n joins?: QueryJoinEdge[]\n profiler?: Profiler\n // When true, suppress automatic reindex scheduling triggered by coverage gap detection.\n // Used by the search indexing pipeline to prevent feedback loops where indexing triggers\n // re-indexing indefinitely.\n skipAutoReindex?: boolean\n /**\n * Force routing this query to custom-entity doc storage (`custom_entities_storage`)\n * instead of classifying the entity automatically. Automatic classification routes\n * ids backed by a registered ORM table to that base table, so surfaces that manage\n * doc records for ids that are ALSO table-backed (e.g. the entities records browser\n * reading a module-declared custom entity such as `example:todo`) must set this flag.\n * Honored by the hybrid query engine only; `BasicQueryEngine` has no doc-storage\n * reader and ignores it.\n */\n forceCustomEntityStorage?: boolean\n // Optional UMES query extensions context. When provided, the engine will\n // emit sync lifecycle events and apply query-level enrichers.\n extensions?: QueryExtensionsConfig\n}\n\nexport type PartialIndexWarning = {\n entity: EntityId\n entityLabel?: string | null\n baseCount?: number | null\n indexedCount?: number | null\n scope?: 'scoped' | 'global'\n}\n\nexport type EncryptedSortRowCapWarning = {\n entity: EntityId\n sortFields: string[]\n maxRows: number\n /**\n * Exact when `meta.listCountCapWarning` is absent; a floor when it is\n * present (the list total itself was bounded at the cap).\n */\n totalMatched: number\n}\n\n/**\n * Present when the list COUNT was bounded at `cap` matching rows\n * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on\n * CRUD list payloads as `totalIsCapped: true`.\n */\nexport type ListCountCapWarning = {\n entity: EntityId\n cap: number\n}\n\nexport type QueryResultMeta = {\n partialIndexWarning?: PartialIndexWarning\n encryptedSortRowCapWarning?: EncryptedSortRowCapWarning\n listCountCapWarning?: ListCountCapWarning\n}\n\nexport type QueryResult<T = any> = {\n items: T[]\n page: number\n pageSize: number\n total: number\n meta?: QueryResultMeta\n /**\n * Custom-field definitions the engine resolved while building this result\n * (only present when `includeCustomFields: true`). Lets the CRUD factory\n * decorate list rows without reloading definitions from the DB (issue #2133).\n * Internal contract \u2014 additive and optional; callers must treat absence as a\n * cue to load definitions themselves.\n */\n customFieldDefinitions?: ResolvedCustomFieldDefinitions\n}\n\nexport interface QueryEngine {\n query<T = any>(entity: EntityId, opts?: QueryOptions): Promise<QueryResult<T>>\n}\n"],
4
+ "sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { Profiler } from '../profiler'\nimport type { ResolvedCustomFieldDefinitions } from '../crud/custom-field-definition-index'\n\n/**\n * `overlap` (additive, 2026-10-05): \"has any of\" over a multi-valued field. An index-document\n * string array compiles to jsonb `?|`, a `cf:*` key to any-value-in-set, and an array-typed base\n * column to `&&`. An empty value list matches nothing.\n *\n * `noverlap` (additive, 2026-10-05): \"has none of\" \u2014 the negation of `overlap` on an index-document\n * string array or an array-typed base column. Only a present array can match (a missing/null key or\n * NULL column never does); an empty value list excludes nothing. Unsupported on `cf:*` keys, where\n * it compiles to `false`.\n */\nexport type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'ilike' | 'exists' | 'overlap' | 'noverlap'\n\nexport enum SortDir {\n Asc = 'asc',\n Desc = 'desc',\n}\n\nexport type FieldSelector = string // base field or custom field key (prefixed with 'cf:')\n\nexport type Filter = {\n field: FieldSelector\n op: FilterOp\n value?: any\n}\n\nexport type Sort = { field: FieldSelector; dir?: SortDir }\n\nexport type Page = { page?: number; pageSize?: number }\n\n// Mongo/Medusa-style filter operators (typed)\nexport type WhereOps<T> = {\n $eq?: T\n $ne?: T | null\n $gt?: T extends number | Date ? T : never\n $gte?: T extends number | Date ? T : never\n $lt?: T extends number | Date ? T : never\n $lte?: T extends number | Date ? T : never\n $in?: T[]\n $nin?: T[]\n $like?: T extends string ? string : never\n $ilike?: T extends string ? string : never\n $exists?: boolean\n $overlap?: T extends readonly (infer Element)[] ? Element[] : T[]\n $noverlap?: T extends readonly (infer Element)[] ? Element[] : T[]\n}\n\n// A field filter can be a direct value (equals) or ops object\nexport type WhereValue<T = any> = T | WhereOps<T>\n\n// Generic shape for object filters. If you have a typed map of field\u2192type,\n// pass it as the generic to get end-to-end typing.\n// Example: Where<{\n// id: string; title: string; created_at: Date; 'cf:severity': number\n// }>\nexport type Where<Fields extends Record<string, any> = Record<string, any>> =\n Partial<{ [K in keyof Fields]: WhereValue<Fields[K]> }> & Record<string, WhereValue>\n\nexport type QueryCustomFieldJoin = {\n fromField: string\n toField: string\n type?: 'left' | 'inner'\n}\n\nexport type QueryCustomFieldSource = {\n entityId: EntityId\n table?: string\n alias?: string\n recordIdColumn?: string\n join?: QueryCustomFieldJoin\n tenantField?: string\n organizationField?: string\n}\n\nexport type QueryJoinEdge = {\n alias: string\n table?: string\n entityId?: EntityId\n from: {\n alias?: string\n field: string\n }\n to: {\n field: string\n }\n type?: 'left' | 'inner'\n}\n\n/**\n * Optional context for query-level UMES extensions.\n * When provided, the query engine will execute sync lifecycle events\n * (querying/queried) and apply query-enabled enrichers.\n */\nexport type QueryExtensionsConfig = {\n userId?: string\n container?: unknown\n userFeatures?: string[]\n resolve?: <T = unknown>(name: string) => T\n}\n\nexport type QueryOptions = {\n fields?: FieldSelector[] // base fields and/or 'cf:<key>' for custom fields\n includeExtensions?: boolean | string[] // include all registered extensions or only specific ones by entity id\n includeCustomFields?: boolean | string[] // include all CFs or specific keys\n // Accept classic array syntax or Mongo-style object syntax\n filters?: Filter[] | Where\n sort?: Sort[]\n page?: Page\n organizationId?: string // enforce multi-tenant scope\n tenantId?: string // enforce tenant scope\n // Optional list of organization ids to scope results. Takes precedence over organizationId.\n organizationIds?: string[]\n /**\n * When true, the engine does not apply default `organization_id` / `tenant_id` equality guards.\n *\n * Callers MUST encode full visibility in `filters` (for example with `$or` of scoped branches)\n * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise\n * queries return cross-tenant rows.\n *\n * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine\n * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read\n * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does\n * not match the standard `organization_id = X AND tenant_id = Y` shape.\n */\n omitAutomaticTenantOrgScope?: boolean\n /**\n * When `false`, the engine returns encrypted fields as stored (ciphertext) instead of decrypting\n * them, and performs no DEK lookup at all. Defaults to `true` \u2014 today's behaviour \u2014 so existing\n * callers are unaffected.\n *\n * Use it on generic, entity-agnostic surfaces that select every column and do not need plaintext.\n * Opting in to decline decryption is not an access control: when decryption DOES run it is bound\n * to the caller's tenant by `resolveDecryptScope`, which refuses any row whose `tenant_id`\n * contradicts `tenantId`.\n *\n * Caveat: declining also disables the plaintext-sort path, so a `sort` on an encrypted field\n * degrades to SQL `ORDER BY` over ciphertext \u2014 a meaningless order. Both engines log one warning\n * when that combination is requested.\n */\n decryptEncryptedFields?: boolean\n // Soft-delete behavior: when false (default), rows with non-null deleted_at\n // are excluded if the base table has that column. Set true to include them.\n withDeleted?: boolean\n customFieldSources?: QueryCustomFieldSource[]\n joins?: QueryJoinEdge[]\n profiler?: Profiler\n // When true, suppress automatic reindex scheduling triggered by coverage gap detection.\n // Used by the search indexing pipeline to prevent feedback loops where indexing triggers\n // re-indexing indefinitely.\n skipAutoReindex?: boolean\n /**\n * Force routing this query to custom-entity doc storage (`custom_entities_storage`)\n * instead of classifying the entity automatically. Automatic classification routes\n * ids backed by a registered ORM table to that base table, so surfaces that manage\n * doc records for ids that are ALSO table-backed (e.g. the entities records browser\n * reading a module-declared custom entity such as `example:todo`) must set this flag.\n * Honored by the hybrid query engine only; `BasicQueryEngine` has no doc-storage\n * reader and ignores it.\n */\n forceCustomEntityStorage?: boolean\n // Optional UMES query extensions context. When provided, the engine will\n // emit sync lifecycle events and apply query-level enrichers.\n extensions?: QueryExtensionsConfig\n}\n\nexport type PartialIndexWarning = {\n entity: EntityId\n entityLabel?: string | null\n baseCount?: number | null\n indexedCount?: number | null\n scope?: 'scoped' | 'global'\n}\n\nexport type EncryptedSortRowCapWarning = {\n entity: EntityId\n sortFields: string[]\n maxRows: number\n /**\n * Exact when `meta.listCountCapWarning` is absent; a floor when it is\n * present (the list total itself was bounded at the cap).\n */\n totalMatched: number\n}\n\n/**\n * Present when the list COUNT was bounded at `cap` matching rows\n * (`OM_LIST_COUNT_CAP`): `total` is a floor, not an exact value. Surfaced on\n * CRUD list payloads as `totalIsCapped: true`.\n */\nexport type ListCountCapWarning = {\n entity: EntityId\n cap: number\n}\n\nexport type QueryResultMeta = {\n partialIndexWarning?: PartialIndexWarning\n encryptedSortRowCapWarning?: EncryptedSortRowCapWarning\n listCountCapWarning?: ListCountCapWarning\n}\n\nexport type QueryResult<T = any> = {\n items: T[]\n page: number\n pageSize: number\n total: number\n meta?: QueryResultMeta\n /**\n * Custom-field definitions the engine resolved while building this result\n * (only present when `includeCustomFields: true`). Lets the CRUD factory\n * decorate list rows without reloading definitions from the DB (issue #2133).\n * Internal contract \u2014 additive and optional; callers must treat absence as a\n * cue to load definitions themselves.\n */\n customFieldDefinitions?: ResolvedCustomFieldDefinitions\n}\n\nexport interface QueryEngine {\n query<T = any>(entity: EntityId, opts?: QueryOptions): Promise<QueryResult<T>>\n}\n"],
5
5
  "mappings": "AAgBO,IAAK,UAAL,kBAAKA,aAAL;AACL,EAAAA,SAAA,SAAM;AACN,EAAAA,SAAA,UAAO;AAFG,SAAAA;AAAA,GAAA;",
6
6
  "names": ["SortDir"]
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.10.1-develop.7679.1.cb2c13f49e";
1
+ const APP_VERSION = "0.10.1-develop.7687.1.e1a6f9de51";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.10.1-develop.7679.1.cb2c13f49e';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.10.1-develop.7687.1.e1a6f9de51';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.10.1-develop.7679.1.cb2c13f49e",
3
+ "version": "0.10.1-develop.7687.1.e1a6f9de51",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -126,7 +126,7 @@
126
126
  "@mikro-orm/core": "^7.1.14",
127
127
  "@mikro-orm/decorators": "^7.1.14",
128
128
  "@mikro-orm/postgresql": "^7.1.14",
129
- "@open-mercato/cache": "0.10.1-develop.7679.1.cb2c13f49e",
129
+ "@open-mercato/cache": "0.10.1-develop.7687.1.e1a6f9de51",
130
130
  "@types/html-to-text": "^9.0.4",
131
131
  "@types/sanitize-html": "^2.16.1",
132
132
  "dotenv": "^17.4.2",
@@ -586,6 +586,22 @@ describe('CRUD Factory', () => {
586
586
  } as any,
587
587
  })
588
588
 
589
+ it('GET forwards list.decryptEncryptedFields=false to the query engine (#5430)', async () => {
590
+ await makeSortedRoute({ decryptEncryptedFields: false }).GET(
591
+ new Request('http://x/api/example/todos?page=1&pageSize=10'),
592
+ )
593
+
594
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
595
+ expect(queryArgs?.decryptEncryptedFields).toBe(false)
596
+ })
597
+
598
+ it('GET leaves decryptEncryptedFields unset when the list does not opt out', async () => {
599
+ await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
600
+
601
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
602
+ expect(queryArgs?.decryptEncryptedFields).toBeUndefined()
603
+ })
604
+
589
605
  it('GET falls back to sorting by id when no default sort is configured', async () => {
590
606
  await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
591
607
 
@@ -271,6 +271,16 @@ export type ListConfig<TList> = {
271
271
  * filters/sorts, `search_tokens` fulltext filtering, and vector-search branches are bypassed.
272
272
  */
273
273
  omitAutomaticTenantOrgScope?: boolean
274
+ /**
275
+ * When false, the query engine returns encrypted fields as stored (ciphertext) and performs no
276
+ * DEK lookup. Defaults to true — today's behaviour. Set it on generic, entity-agnostic list
277
+ * surfaces that select every column and do not need plaintext.
278
+ *
279
+ * Caveat: declining also disables the plaintext-sort path, so a sort on an encrypted field
280
+ * degrades to SQL `ORDER BY` over ciphertext. The query engine logs one warning when that
281
+ * combination is requested.
282
+ */
283
+ decryptEncryptedFields?: boolean
274
284
  /** When true, skip server-side CRUD GET cache for this list (avoids stale empty payloads after mutations). */
275
285
  disableListCache?: boolean
276
286
  }
@@ -1977,6 +1987,9 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1977
1987
  if (opts.list.omitAutomaticTenantOrgScope) {
1978
1988
  queryOpts.omitAutomaticTenantOrgScope = true
1979
1989
  }
1990
+ if (opts.list.decryptEncryptedFields === false) {
1991
+ queryOpts.decryptEncryptedFields = false
1992
+ }
1980
1993
  const queryEntity = String(opts.list.entityId)
1981
1994
  profiler.mark('query_options_ready')
1982
1995
  const queryProfiler = profiler.child('query_engine', { entity: queryEntity })
@@ -0,0 +1,134 @@
1
+ import {
2
+ DecryptRefusalTally,
3
+ resolveDecryptEnabled,
4
+ resolveDecryptScope,
5
+ } from '../decryptScope'
6
+
7
+ describe('resolveDecryptScope', () => {
8
+ test('decrypts when the row tenant matches the caller tenant', () => {
9
+ const decision = resolveDecryptScope({
10
+ rowTenantId: 't1',
11
+ rowOrganizationId: 'org1',
12
+ callerTenantId: 't1',
13
+ callerOrganizationId: 'org9',
14
+ })
15
+ expect(decision).toEqual({ decrypt: true, tenantId: 't1', organizationId: 'org1' })
16
+ })
17
+
18
+ test('refuses when the row tenant contradicts the caller tenant', () => {
19
+ const decision = resolveDecryptScope({
20
+ rowTenantId: 't2',
21
+ rowOrganizationId: 'org1',
22
+ callerTenantId: 't1',
23
+ callerOrganizationId: 'org1',
24
+ })
25
+ expect(decision).toEqual({
26
+ decrypt: false,
27
+ reason: 'tenant-mismatch',
28
+ rowTenantId: 't2',
29
+ callerTenantId: 't1',
30
+ })
31
+ })
32
+
33
+ test('falls back to the caller scope when the row carries no tenant', () => {
34
+ const decision = resolveDecryptScope({
35
+ rowTenantId: null,
36
+ rowOrganizationId: null,
37
+ callerTenantId: 't1',
38
+ callerOrganizationId: 'org1',
39
+ })
40
+ expect(decision).toEqual({ decrypt: true, tenantId: 't1', organizationId: 'org1' })
41
+ })
42
+
43
+ test('keeps the row tenant for a deliberate cross-tenant read that asserts no caller tenant', () => {
44
+ const decision = resolveDecryptScope({
45
+ rowTenantId: 't2',
46
+ rowOrganizationId: 'org2',
47
+ callerTenantId: null,
48
+ callerOrganizationId: null,
49
+ })
50
+ expect(decision).toEqual({ decrypt: true, tenantId: 't2', organizationId: 'org2' })
51
+ })
52
+
53
+ test('resolves a null scope when neither side carries ids', () => {
54
+ const decision = resolveDecryptScope({})
55
+ expect(decision).toEqual({ decrypt: true, tenantId: null, organizationId: null })
56
+ })
57
+
58
+ test('never refuses on an organization mismatch alone', () => {
59
+ const decision = resolveDecryptScope({
60
+ rowTenantId: 't1',
61
+ rowOrganizationId: 'org2',
62
+ callerTenantId: 't1',
63
+ callerOrganizationId: 'org1',
64
+ })
65
+ expect(decision).toEqual({ decrypt: true, tenantId: 't1', organizationId: 'org2' })
66
+ })
67
+
68
+ test('prefers the row organization and falls back to the caller organization', () => {
69
+ expect(resolveDecryptScope({ rowTenantId: 't1', callerTenantId: 't1', callerOrganizationId: 'org1' }))
70
+ .toEqual({ decrypt: true, tenantId: 't1', organizationId: 'org1' })
71
+ })
72
+ })
73
+
74
+ describe('resolveDecryptEnabled', () => {
75
+ test('defaults to decrypting, so existing callers are unaffected', () => {
76
+ expect(resolveDecryptEnabled({})).toBe(true)
77
+ expect(resolveDecryptEnabled({ decryptEncryptedFields: undefined })).toBe(true)
78
+ })
79
+
80
+ test('only an explicit false declines decryption', () => {
81
+ expect(resolveDecryptEnabled({ decryptEncryptedFields: false })).toBe(false)
82
+ expect(resolveDecryptEnabled({ decryptEncryptedFields: true })).toBe(true)
83
+ })
84
+ })
85
+
86
+ describe('DecryptRefusalTally', () => {
87
+ const refusal = (rowTenantId: string, callerTenantId = 't1') =>
88
+ ({ decrypt: false, reason: 'tenant-mismatch', rowTenantId, callerTenantId }) as const
89
+
90
+ test('starts empty', () => {
91
+ expect(new DecryptRefusalTally().refused).toBe(0)
92
+ })
93
+
94
+ test('counts every refusal but samples a bounded set of row tenant ids', () => {
95
+ const tally = new DecryptRefusalTally()
96
+ for (const tenant of ['t2', 't3', 't4', 't5', 't6']) tally.record(refusal(tenant))
97
+ expect(tally.refused).toBe(5)
98
+ const context = tally.toLogContext('customers:customer')
99
+ expect(context.refusedRows).toBe(5)
100
+ expect(context.callerTenantId).toBe('t1')
101
+ expect(context.rowTenantIds).toEqual(['t2', 't3', 't4'])
102
+ })
103
+
104
+ // The plaintext-sort path decrypts an overlapping row set twice (candidate scan, then page
105
+ // rows), so counting both passes overstated the refusal count by up to a page.
106
+ test('counts a row once however many times it is recorded', () => {
107
+ const tally = new DecryptRefusalTally()
108
+ tally.record(refusal('t2'), 'row-1')
109
+ tally.record(refusal('t3'), 'row-2')
110
+ tally.record(refusal('t2'), 'row-1')
111
+ expect(tally.refused).toBe(2)
112
+ expect(tally.toLogContext('customers:customer').refusedRows).toBe(2)
113
+ })
114
+
115
+ test('counts rows recorded without an id individually', () => {
116
+ const tally = new DecryptRefusalTally()
117
+ tally.record(refusal('t2'))
118
+ tally.record(refusal('t2'), null)
119
+ tally.record(refusal('t2'), '')
120
+ tally.record(refusal('t2'), 'row-1')
121
+ expect(tally.refused).toBe(4)
122
+ })
123
+
124
+ test('log context carries ids only — never row values', () => {
125
+ const tally = new DecryptRefusalTally()
126
+ tally.record(refusal('t2'))
127
+ expect(Object.keys(tally.toLogContext('customers:customer')).sort()).toEqual([
128
+ 'callerTenantId',
129
+ 'entity',
130
+ 'refusedRows',
131
+ 'rowTenantIds',
132
+ ])
133
+ })
134
+ })
@@ -0,0 +1,234 @@
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
+ jest.mock('../likeFilterWarning', () => ({ warnOnEncryptedLikeFilter: jest.fn(async () => undefined) }))
9
+
10
+ import { ReferenceKind } from '@mikro-orm/core'
11
+ import { TenantEncryptionSubscriber } from '../subscriber'
12
+ import { findAndCountWithDecryption, findOneWithDecryption, findWithDecryption } from '../find'
13
+ import { DECRYPT_REFUSAL_LOG_MESSAGE } from '../decryptScope'
14
+ import { registerEntityIds } from '../entityIds'
15
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
16
+
17
+ // The ORM half of #5430 as it runs in production: `em.find` hydrates entities and fires the
18
+ // registered subscriber's `onLoad` BEFORE `find*WithDecryption` gets to apply the caller's scope.
19
+ // The fake EntityManager below mirrors that order, including an async gap before hydration, so a
20
+ // binding that only engages after `em.find` returns cannot pass.
21
+
22
+ const CHILD_META = { className: 'Child', tableName: 'children', properties: {} } as any
23
+
24
+ const LINK_META = {
25
+ className: 'Link',
26
+ tableName: 'links',
27
+ properties: { child: { name: 'child', kind: ReferenceKind.MANY_TO_ONE } },
28
+ } as any
29
+
30
+ type Call = { entityId: string; tenantId: string | null }
31
+
32
+ function makeService(calls: Call[]): TenantDataEncryptionService {
33
+ return {
34
+ isEnabled: () => true,
35
+ async decryptEntityPayload(entityId: string, target: Record<string, unknown>, tenantId: string | null) {
36
+ calls.push({ entityId, tenantId })
37
+ const value = target.secret
38
+ if (typeof value === 'string' && value.startsWith('enc:')) return { secret: value.slice('enc:'.length) }
39
+ return {}
40
+ },
41
+ } as unknown as TenantDataEncryptionService
42
+ }
43
+
44
+ function makeHydratingEm(subscriber: TenantEncryptionSubscriber, rows: Array<Record<string, unknown>>) {
45
+ const hydrate = async () => {
46
+ await new Promise((resolve) => setImmediate(resolve))
47
+ for (const row of rows) {
48
+ await subscriber.onLoad({ entity: row, meta: row.__meta, em } as any)
49
+ }
50
+ }
51
+ const em = {
52
+ getMetadata: () => undefined,
53
+ getComparator: () => undefined,
54
+ async find() {
55
+ await hydrate()
56
+ return rows
57
+ },
58
+ async findOne() {
59
+ await hydrate()
60
+ return rows[0] ?? null
61
+ },
62
+ async findAndCount() {
63
+ await hydrate()
64
+ return [rows, rows.length]
65
+ },
66
+ }
67
+ return em
68
+ }
69
+
70
+ const refusalWarnings = () => warnMock.mock.calls.filter(([message]) => message === DECRYPT_REFUSAL_LOG_MESSAGE)
71
+
72
+ describe('find*WithDecryption tenant binding through the ORM onLoad hook (#5430)', () => {
73
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
74
+
75
+ beforeEach(() => {
76
+ warnMock.mockClear()
77
+ delete process.env.TENANT_DATA_ENCRYPTION
78
+ registerEntityIds({ test: { child: 'test:child', link: 'test:link' } })
79
+ })
80
+
81
+ afterEach(() => {
82
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
83
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
84
+ })
85
+
86
+ it('refuses a foreign-tenant entity during hydration and reports the read once', async () => {
87
+ const calls: Call[] = []
88
+ const service = makeService(calls)
89
+ const own = { id: 'own', secret: 'enc:alpha', tenantId: 'tenant-a', __meta: CHILD_META }
90
+ const foreignOne = { id: 'f1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
91
+ const foreignTwo = { id: 'f2', secret: 'enc:gamma', tenantId: 'tenant-b', __meta: CHILD_META }
92
+ const em = makeHydratingEm(new TenantEncryptionSubscriber(service), [own, foreignOne, foreignTwo])
93
+
94
+ await findWithDecryption(em as any, 'Child' as any, {}, undefined, {
95
+ tenantId: 'tenant-a',
96
+ organizationId: null,
97
+ encryptionService: service,
98
+ })
99
+
100
+ expect(calls.every((call) => call.tenantId === 'tenant-a')).toBe(true)
101
+ expect(own.secret).toBe('alpha')
102
+ expect(foreignOne.secret).toBe('enc:beta')
103
+ expect(foreignTwo.secret).toBe('enc:gamma')
104
+ expect(refusalWarnings()).toHaveLength(1)
105
+ expect(refusalWarnings()[0][1]).toMatchObject({ entity: 'test:child', refusedRows: 2, callerTenantId: 'tenant-a' })
106
+ })
107
+
108
+ it('applies the binding to findOne and findAndCount reads as well', async () => {
109
+ const calls: Call[] = []
110
+ const service = makeService(calls)
111
+ const foreign = { id: 'f1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
112
+ const subscriber = new TenantEncryptionSubscriber(service)
113
+ const scope = { tenantId: 'tenant-a', organizationId: null, encryptionService: service }
114
+
115
+ await findOneWithDecryption(makeHydratingEm(subscriber, [foreign]) as any, 'Child' as any, {}, undefined, scope)
116
+ await findAndCountWithDecryption(makeHydratingEm(subscriber, [foreign]) as any, 'Child' as any, {}, undefined, scope)
117
+
118
+ expect(calls).toEqual([])
119
+ expect(foreign.secret).toBe('enc:beta')
120
+ })
121
+
122
+ it('keeps decrypting with the row tenant when the read asserts no tenant', async () => {
123
+ const calls: Call[] = []
124
+ const service = makeService(calls)
125
+ const foreign = { id: 'f1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
126
+ const em = makeHydratingEm(new TenantEncryptionSubscriber(service), [foreign])
127
+
128
+ await findWithDecryption(em as any, 'Child' as any, {}, undefined, { encryptionService: service })
129
+
130
+ expect(calls[0]).toEqual({ entityId: 'test:child', tenantId: 'tenant-b' })
131
+ expect(foreign.secret).toBe('beta')
132
+ expect(refusalWarnings()).toHaveLength(0)
133
+ })
134
+
135
+ it('does not hand an outer read scope to a nested read that asserts none', async () => {
136
+ const calls: Call[] = []
137
+ const service = makeService(calls)
138
+ const subscriber = new TenantEncryptionSubscriber(service)
139
+ const foreign = { id: 'f1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
140
+ const outerEm = makeHydratingEm(subscriber, [])
141
+ outerEm.find = async () => {
142
+ await findWithDecryption(makeHydratingEm(subscriber, [foreign]) as any, 'Child' as any, {}, undefined, {
143
+ encryptionService: service,
144
+ })
145
+ return []
146
+ }
147
+
148
+ await findWithDecryption(outerEm as any, 'Child' as any, {}, undefined, {
149
+ tenantId: 'tenant-a',
150
+ encryptionService: service,
151
+ })
152
+
153
+ expect(calls).toEqual([
154
+ { entityId: 'test:child', tenantId: 'tenant-b' },
155
+ { entityId: 'test:child', tenantId: 'tenant-b' },
156
+ ])
157
+ expect(foreign.secret).toBe('beta')
158
+ })
159
+
160
+ it('does not bind the caller scope outside the read that asserted it', async () => {
161
+ const calls: Call[] = []
162
+ const subscriber = new TenantEncryptionSubscriber(makeService(calls))
163
+ const foreign = { id: 'f1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
164
+
165
+ await subscriber.onLoad({ entity: foreign, meta: CHILD_META, em: makeHydratingEm(subscriber, []) } as any)
166
+
167
+ expect(calls).toEqual([{ entityId: 'test:child', tenantId: 'tenant-b' }])
168
+ expect(foreign.secret).toBe('beta')
169
+ })
170
+ })
171
+
172
+ describe('relation scope inherited from a tenant-less parent (#5430)', () => {
173
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
174
+
175
+ beforeEach(() => {
176
+ warnMock.mockClear()
177
+ delete process.env.TENANT_DATA_ENCRYPTION
178
+ registerEntityIds({ test: { child: 'test:child', link: 'test:link' } })
179
+ })
180
+
181
+ afterEach(() => {
182
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
183
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
184
+ })
185
+
186
+ it('does not refuse a relation of a tenant-less parent against the caller tenant', async () => {
187
+ const calls: Call[] = []
188
+ const child = { id: 'c1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
189
+ const link = { id: 'l1', child: { isInitialized: () => true, unwrap: () => child }, __meta: LINK_META }
190
+
191
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(link, LINK_META, undefined, {
192
+ syncOriginal: true,
193
+ fallbackScope: { tenantId: 'tenant-a', organizationId: null },
194
+ })
195
+
196
+ expect(calls).toContainEqual({ entityId: 'test:child', tenantId: 'tenant-b' })
197
+ expect(child.secret).toBe('beta')
198
+ expect(refusalWarnings()).toHaveLength(0)
199
+ })
200
+
201
+ it('still keys a tenant-less relation of a tenant-less parent off the caller tenant', async () => {
202
+ const calls: Call[] = []
203
+ const child = { id: 'c1', secret: 'enc:beta', __meta: CHILD_META }
204
+ const link = { id: 'l1', child: { isInitialized: () => true, unwrap: () => child }, __meta: LINK_META }
205
+
206
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(link, LINK_META, undefined, {
207
+ syncOriginal: true,
208
+ fallbackScope: { tenantId: 'tenant-a', organizationId: null },
209
+ })
210
+
211
+ expect(calls).toContainEqual({ entityId: 'test:child', tenantId: 'tenant-a' })
212
+ expect(child.secret).toBe('beta')
213
+ })
214
+
215
+ it('keeps binding a relation reached through a parent of the caller tenant', async () => {
216
+ const calls: Call[] = []
217
+ const child = { id: 'c1', secret: 'enc:beta', tenantId: 'tenant-b', __meta: CHILD_META }
218
+ const link = {
219
+ id: 'l1',
220
+ tenantId: 'tenant-a',
221
+ child: { isInitialized: () => true, unwrap: () => child },
222
+ __meta: LINK_META,
223
+ }
224
+
225
+ await new TenantEncryptionSubscriber(makeService(calls)).decryptEntityGraph(link, LINK_META, undefined, {
226
+ syncOriginal: true,
227
+ fallbackScope: { tenantId: 'tenant-a', organizationId: null },
228
+ })
229
+
230
+ expect(calls.some((call) => call.entityId === 'test:child')).toBe(false)
231
+ expect(child.secret).toBe('enc:beta')
232
+ expect(refusalWarnings()).toHaveLength(1)
233
+ })
234
+ })
@@ -0,0 +1,105 @@
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 { decryptEntitiesWithFallbackScope, TenantEncryptionSubscriber } from '../subscriber'
9
+ import { DECRYPT_REFUSAL_LOG_MESSAGE } from '../decryptScope'
10
+ import { registerEntityIds } from '../entityIds'
11
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
12
+
13
+ // A mis-scoped `findWithDecryption` refuses its whole result list. The docs promise one aggregated
14
+ // warning per read; the ORM path used to emit one line per entity, so a page of 500 foreign rows
15
+ // produced 500 warnings.
16
+
17
+ const CHILD_META = { className: 'Child', tableName: 'children', properties: {} } as any
18
+
19
+ const makeEm = () => ({ getMetadata: () => undefined, getComparator: () => undefined })
20
+
21
+ const makeService = (): TenantDataEncryptionService =>
22
+ ({
23
+ isEnabled: () => true,
24
+ async decryptEntityPayload(_entityId: string, target: Record<string, unknown>) {
25
+ const value = target.secret
26
+ if (typeof value === 'string' && value.startsWith('enc:')) return { secret: value.slice('enc:'.length) }
27
+ return {}
28
+ },
29
+ }) as unknown as TenantDataEncryptionService
30
+
31
+ const refusalWarnings = () => warnMock.mock.calls.filter(([message]) => message === DECRYPT_REFUSAL_LOG_MESSAGE)
32
+
33
+ describe('ORM decrypt-refusal aggregation (#5430)', () => {
34
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
35
+
36
+ beforeEach(() => {
37
+ warnMock.mockClear()
38
+ delete process.env.TENANT_DATA_ENCRYPTION
39
+ registerEntityIds({ test: { child: 'test:child' } })
40
+ })
41
+
42
+ afterEach(() => {
43
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
44
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
45
+ })
46
+
47
+ it('emits one warning for a whole refused batch, not one per entity', async () => {
48
+ const entities = ['tenant-b', 'tenant-c', 'tenant-d', 'tenant-e'].map((tenantId, index) => ({
49
+ id: `row-${index}`,
50
+ secret: 'enc:alpha',
51
+ tenantId,
52
+ __meta: CHILD_META,
53
+ }))
54
+
55
+ await decryptEntitiesWithFallbackScope(entities, {
56
+ em: makeEm() as any,
57
+ tenantId: 'tenant-a',
58
+ encryptionService: makeService(),
59
+ })
60
+
61
+ const warnings = refusalWarnings()
62
+ expect(warnings).toHaveLength(1)
63
+ expect(warnings[0][1]).toEqual({
64
+ entity: 'test:child',
65
+ refusedRows: 4,
66
+ callerTenantId: 'tenant-a',
67
+ rowTenantIds: ['tenant-b', 'tenant-c', 'tenant-d'],
68
+ })
69
+ for (const entity of entities) expect(entity.secret).toBe('enc:alpha')
70
+ })
71
+
72
+ it('emits no warning when the batch is correctly scoped', async () => {
73
+ const entities = [
74
+ { id: 'row-0', secret: 'enc:alpha', tenantId: 'tenant-a', __meta: CHILD_META },
75
+ { id: 'row-1', secret: 'enc:beta', tenantId: 'tenant-a', __meta: CHILD_META },
76
+ ]
77
+
78
+ await decryptEntitiesWithFallbackScope(entities, {
79
+ em: makeEm() as any,
80
+ tenantId: 'tenant-a',
81
+ encryptionService: makeService(),
82
+ })
83
+
84
+ expect(refusalWarnings()).toHaveLength(0)
85
+ expect(entities.map((entity) => entity.secret)).toEqual(['alpha', 'beta'])
86
+ })
87
+
88
+ it('still reports immediately for a single-entity call that takes no tally', async () => {
89
+ const entity = { id: 'row-0', secret: 'enc:alpha', tenantId: 'tenant-b', __meta: CHILD_META }
90
+
91
+ await new TenantEncryptionSubscriber(makeService()).decryptEntityGraph(entity, CHILD_META, makeEm() as any, {
92
+ syncOriginal: true,
93
+ fallbackScope: { tenantId: 'tenant-a', organizationId: null },
94
+ })
95
+
96
+ const warnings = refusalWarnings()
97
+ expect(warnings).toHaveLength(1)
98
+ expect(warnings[0][1]).toEqual({
99
+ entity: 'test:child',
100
+ refusedRows: 1,
101
+ callerTenantId: 'tenant-a',
102
+ rowTenantIds: ['tenant-b'],
103
+ })
104
+ })
105
+ })