@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
|
@@ -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
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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
|
+
})
|