@open-mercato/core 0.7.1-develop.7183.1.db9678eeb8 → 0.7.1-develop.7186.1.6e080a5017
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/dist/modules/api_keys/services/apiKeyService.js +23 -7
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +69 -6
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/integrations/api/[id]/credentials/route.js +24 -4
- package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +16 -4
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/integrations/lib/log-service.js +33 -0
- package/dist/modules/integrations/lib/log-service.js.map +2 -2
- package/dist/modules/payment_gateways/workers/status-poller.js +5 -1
- package/dist/modules/payment_gateways/workers/status-poller.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +61 -8
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/services/apiKeyService.ts +43 -11
- package/src/modules/data_sync/AGENTS.md +2 -1
- package/src/modules/data_sync/lib/sync-engine.ts +122 -7
- package/src/modules/integrations/api/[id]/credentials/route.ts +39 -3
- package/src/modules/integrations/lib/credentials-service.ts +51 -5
- package/src/modules/integrations/lib/log-service.ts +57 -0
- package/src/modules/payment_gateways/workers/status-poller.ts +5 -1
- package/src/modules/query_index/lib/search-tokens.ts +129 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import { decryptWithAesGcm, encryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
|
|
3
3
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
-
import { createKmsService } from '@open-mercato/shared/lib/encryption/kms'
|
|
4
|
+
import { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'
|
|
5
5
|
import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
6
6
|
import {
|
|
7
7
|
getBundle,
|
|
@@ -21,15 +21,30 @@ const ENCRYPTED_CREDENTIALS_BLOB_KEY = '__om_encrypted_credentials_blob_v1'
|
|
|
21
21
|
* configured. The credentials path deliberately fails closed instead of using
|
|
22
22
|
* a hardcoded fallback secret; see security tracker finding #7.
|
|
23
23
|
*/
|
|
24
|
+
export type CredentialsEncryptionUnavailableReason = 'no-dek' | 'sealed-while-disabled'
|
|
25
|
+
|
|
26
|
+
const CREDENTIALS_ENCRYPTION_REMEDY: Record<CredentialsEncryptionUnavailableReason, string> = {
|
|
27
|
+
'no-dek':
|
|
28
|
+
'no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or ' +
|
|
29
|
+
'set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.',
|
|
30
|
+
'sealed-while-disabled':
|
|
31
|
+
'they were sealed while TENANT_DATA_ENCRYPTION was on and it is now off, so no key can open ' +
|
|
32
|
+
'them. Re-enable TENANT_DATA_ENCRYPTION to read them again, or re-enter the credentials — ' +
|
|
33
|
+
'saving them while the toggle is off stores them in the clear. Note that ' +
|
|
34
|
+
'`mercato entities decrypt-database` does not reach this blob: it decrypts the columns an ' +
|
|
35
|
+
'encryption map covers, and this envelope sits inside the decrypted value.',
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
export class CredentialsEncryptionUnavailableError extends Error {
|
|
25
39
|
readonly code = 'CREDENTIALS_ENCRYPTION_UNAVAILABLE'
|
|
26
|
-
|
|
40
|
+
readonly reason: CredentialsEncryptionUnavailableReason
|
|
41
|
+
constructor(tenantId: string, reason: CredentialsEncryptionUnavailableReason = 'no-dek') {
|
|
27
42
|
super(
|
|
28
43
|
`Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: ` +
|
|
29
|
-
|
|
30
|
-
`set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.`,
|
|
44
|
+
CREDENTIALS_ENCRYPTION_REMEDY[reason],
|
|
31
45
|
)
|
|
32
46
|
this.name = 'CredentialsEncryptionUnavailableError'
|
|
47
|
+
this.reason = reason
|
|
33
48
|
}
|
|
34
49
|
}
|
|
35
50
|
|
|
@@ -37,6 +52,16 @@ export function isCredentialsEncryptionUnavailableError(error: unknown): error i
|
|
|
37
52
|
return error instanceof CredentialsEncryptionUnavailableError
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The one unavailable-reason an operator can act on without restoring a key: the blob predates
|
|
57
|
+
* `TENANT_DATA_ENCRYPTION=no` and no key exists to open it, so re-entering the credentials is the
|
|
58
|
+
* only way forward. The admin credentials route degrades to an empty form on this so that the
|
|
59
|
+
* re-entry is possible at all; `no-dek` (encryption on, KMS unreachable) still fails closed.
|
|
60
|
+
*/
|
|
61
|
+
export function isCredentialsSealedWhileDisabledError(error: unknown): boolean {
|
|
62
|
+
return isCredentialsEncryptionUnavailableError(error) && error.reason === 'sealed-while-disabled'
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
41
66
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
42
67
|
}
|
|
@@ -112,8 +137,21 @@ export function createCredentialsService(em: EntityManager) {
|
|
|
112
137
|
existing.isActive = true
|
|
113
138
|
}
|
|
114
139
|
|
|
115
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the DEK this tenant's credentials blob is sealed with, or `null` when the operator
|
|
142
|
+
* has switched tenant data encryption off.
|
|
143
|
+
*
|
|
144
|
+
* The `null` is the whole point of going through {@link resolveEncryptionMode} rather than
|
|
145
|
+
* asking the KMS directly. Under `TENANT_DATA_ENCRYPTION=no` the KMS is a noop and hands back
|
|
146
|
+
* nothing, which is indistinguishable — to `getTenantDek` alone — from Vault being down. Those
|
|
147
|
+
* two need opposite answers: an operator who turned encryption off expects plaintext, whereas a
|
|
148
|
+
* Vault outage must not silently downgrade a secret that is supposed to be sealed. So only
|
|
149
|
+
* `unavailable` throws.
|
|
150
|
+
*/
|
|
151
|
+
async function resolveCredentialsDek(scope: IntegrationScope): Promise<string | null> {
|
|
116
152
|
const kms = createKmsService()
|
|
153
|
+
if (resolveEncryptionMode(kms) === 'disabled') return null
|
|
154
|
+
|
|
117
155
|
const existing = await kms.getTenantDek(scope.tenantId)
|
|
118
156
|
if (existing?.key) return existing.key
|
|
119
157
|
|
|
@@ -128,6 +166,7 @@ export function createCredentialsService(em: EntityManager) {
|
|
|
128
166
|
scope: IntegrationScope,
|
|
129
167
|
): Promise<Record<string, unknown>> {
|
|
130
168
|
const dek = await resolveCredentialsDek(scope)
|
|
169
|
+
if (!dek) return credentials
|
|
131
170
|
const payload = encryptWithAesGcm(JSON.stringify(credentials), dek)
|
|
132
171
|
return { [ENCRYPTED_CREDENTIALS_BLOB_KEY]: payload.value }
|
|
133
172
|
}
|
|
@@ -140,7 +179,14 @@ export function createCredentialsService(em: EntityManager) {
|
|
|
140
179
|
const encrypted = credentials[ENCRYPTED_CREDENTIALS_BLOB_KEY]
|
|
141
180
|
if (typeof encrypted !== 'string' || !encrypted) return credentials
|
|
142
181
|
|
|
182
|
+
// A sealed blob written before encryption was switched off. There is no key to open it with,
|
|
183
|
+
// and returning the envelope as if it were the credentials would hand an adapter a garbage
|
|
184
|
+
// secret, so this stays an error even in `disabled` mode rather than a silent empty credential
|
|
185
|
+
// set. The remedy is re-entering the credentials (see the reason's message); the admin route
|
|
186
|
+
// catches this specific reason so the form can load empty and accept them.
|
|
143
187
|
const dek = await resolveCredentialsDek(scope)
|
|
188
|
+
if (!dek) throw new CredentialsEncryptionUnavailableError(scope.tenantId, 'sealed-while-disabled')
|
|
189
|
+
|
|
144
190
|
const decryptedRaw = decryptWithAesGcm(encrypted, dek)
|
|
145
191
|
if (!decryptedRaw) return {}
|
|
146
192
|
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
2
2
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
5
|
+
import { groupableCode } from '@open-mercato/shared/lib/telemetry/error-code'
|
|
3
6
|
import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
|
|
4
7
|
import type { ListIntegrationLogsQuery } from '../data/validators'
|
|
5
8
|
import { IntegrationLog } from '../data/entities'
|
|
6
9
|
|
|
10
|
+
const logger = createLogger('integrations')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The error an `integration_logs` row at `level: 'error'` is reported as.
|
|
14
|
+
*
|
|
15
|
+
* A named type rather than a bare `Error` because every integration failure in
|
|
16
|
+
* the product funnels through one line below: backends that fingerprint on the
|
|
17
|
+
* error class would otherwise group these together with unrelated framework
|
|
18
|
+
* errors. The per-row `code` is what separates them from each other.
|
|
19
|
+
*/
|
|
20
|
+
export class IntegrationLogError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'IntegrationLogError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
export type IntegrationLogAnalytics = {
|
|
8
28
|
lastActivityAt: string | null
|
|
9
29
|
totalCount: number
|
|
@@ -38,6 +58,42 @@ type LogInput = {
|
|
|
38
58
|
payload?: Record<string, unknown> | null
|
|
39
59
|
}
|
|
40
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Report an error row outward, so recording it is also reporting it.
|
|
63
|
+
*
|
|
64
|
+
* The row is the durable record; this is the signal. Message, `code` and opaque
|
|
65
|
+
* ids only — `payload` carries the failed item itself and MUST NOT leave the
|
|
66
|
+
* database. Wrapped because observability may never alter behaviour: the row is
|
|
67
|
+
* already flushed by the time this runs, and a telemetry fault degrades to a
|
|
68
|
+
* warning rather than failing the write its caller depends on.
|
|
69
|
+
*
|
|
70
|
+
* The row's own `code` is free-form and written by any module that resolves this
|
|
71
|
+
* service, third-party ones included, so it is narrowed to a fingerprint before
|
|
72
|
+
* it is reported: a code the backend cannot group on is worth less than the
|
|
73
|
+
* catch-all every integration error already shares.
|
|
74
|
+
*/
|
|
75
|
+
function reportErrorLog(input: LogInput, scope: IntegrationScope): void {
|
|
76
|
+
try {
|
|
77
|
+
getTelemetryRuntime()?.reportError(new IntegrationLogError(input.message), {
|
|
78
|
+
module: 'integrations',
|
|
79
|
+
code: groupableCode(input.code, 'integrations.log_error'),
|
|
80
|
+
attributes: {
|
|
81
|
+
integrationId: input.integrationId,
|
|
82
|
+
runId: input.runId ?? undefined,
|
|
83
|
+
scopeEntityType: input.scopeEntityType ?? undefined,
|
|
84
|
+
scopeEntityId: input.scopeEntityId ?? undefined,
|
|
85
|
+
organizationId: scope.organizationId,
|
|
86
|
+
tenantId: scope.tenantId,
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
} catch (telemetryError) {
|
|
90
|
+
logger.warn('Failed to report an integration error log to telemetry', {
|
|
91
|
+
integrationId: input.integrationId,
|
|
92
|
+
err: telemetryError as Error,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
41
97
|
export function createIntegrationLogService(em: EntityManager) {
|
|
42
98
|
return {
|
|
43
99
|
async write(input: LogInput, scope: IntegrationScope): Promise<IntegrationLog> {
|
|
@@ -54,6 +110,7 @@ export function createIntegrationLogService(em: EntityManager) {
|
|
|
54
110
|
tenantId: scope.tenantId,
|
|
55
111
|
})
|
|
56
112
|
await em.persist(row).flush()
|
|
113
|
+
if (input.level === 'error') reportErrorLog(input, scope)
|
|
57
114
|
return row
|
|
58
115
|
},
|
|
59
116
|
|
|
@@ -44,7 +44,11 @@ export default async function handle(job: QueuedJob<PollerJobPayload>, ctx: Hand
|
|
|
44
44
|
scopeEntityType: 'payment_transaction',
|
|
45
45
|
scopeEntityId: transaction.id,
|
|
46
46
|
level: 'error',
|
|
47
|
-
message
|
|
47
|
+
// The cause belongs in the message, not only in `payload`: the payload is
|
|
48
|
+
// the durable record and never leaves the database, so an operator paged by
|
|
49
|
+
// the reported error would otherwise learn only that a gateway failed.
|
|
50
|
+
message: `Payment status polling failed: ${message}`,
|
|
51
|
+
code: 'payment_gateways.status_poll_failed',
|
|
48
52
|
payload: {
|
|
49
53
|
transactionId: transaction.id,
|
|
50
54
|
message,
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
type SearchConfig,
|
|
7
7
|
} from '@open-mercato/shared/lib/search/config'
|
|
8
8
|
import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
|
|
9
|
+
import { looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'
|
|
10
|
+
import { createKmsService, resolveEncryptionMode, type KmsService } from '@open-mercato/shared/lib/encryption/kms'
|
|
9
11
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
10
12
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
11
13
|
|
|
@@ -37,6 +39,8 @@ type BuildTokenOptions = {
|
|
|
37
39
|
tenantId?: string | null
|
|
38
40
|
doc?: Record<string, unknown> | null
|
|
39
41
|
config?: SearchConfig
|
|
42
|
+
/** Resolved once per write by the exported entry points; see {@link shouldGuardCiphertext}. */
|
|
43
|
+
guardCiphertext?: boolean
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
const DEFAULT_SCOPE = { organizationId: null, tenantId: null }
|
|
@@ -68,6 +72,87 @@ function collectTextValues(value: unknown): string[] {
|
|
|
68
72
|
return []
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
let guardKmsService: KmsService | null = null
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether the ciphertext guard below is allowed to run for this write.
|
|
79
|
+
*
|
|
80
|
+
* The guard recognises an envelope by its SHAPE, which is forgeable: `<16 b64>:<b64>:<24 b64>:v1`
|
|
81
|
+
* is a string any user can type into a searchable field. `tenantDataEncryptionService` removed the
|
|
82
|
+
* same structural test for that reason (#2720). So the guard may only run where the shape is the
|
|
83
|
+
* ONLY test available -- which is exactly where no DEK is reachable:
|
|
84
|
+
*
|
|
85
|
+
* - `active` -- the indexer decrypted the document before handing it here, so a value still
|
|
86
|
+
* shaped like an envelope is plaintext somebody typed. Indexing it is correct,
|
|
87
|
+
* and skipping it would let that person freeze their own record's tokens at a
|
|
88
|
+
* past state. The guard stays off, which also keeps it off the hot path of
|
|
89
|
+
* every normal deployment.
|
|
90
|
+
* - `disabled` -- `decryptIndexDocForSearch` is a no-op, so ciphertext arrives undecrypted.
|
|
91
|
+
* - `unavailable` -- the decrypt was attempted and could not complete, same outcome.
|
|
92
|
+
*
|
|
93
|
+
* Resolved once per write rather than per document, over a KMS built once per process:
|
|
94
|
+
* {@link createKmsService} logs when it falls back, and a reindex calls this once per record. The
|
|
95
|
+
* toggle itself is still re-read every call by {@link resolveEncryptionMode}; only the KMS is
|
|
96
|
+
* cached, and it already requires a restart to change, since DEK and map caches are in-process.
|
|
97
|
+
*/
|
|
98
|
+
function shouldGuardCiphertext(): boolean {
|
|
99
|
+
guardKmsService ??= createKmsService()
|
|
100
|
+
return resolveEncryptionMode(guardKmsService) !== 'active'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fields whose value is an AES-GCM envelope rather than the text it is supposed to hold.
|
|
105
|
+
*
|
|
106
|
+
* Search tokens are hashes of PLAINTEXT: the indexer decrypts a document before tokenising it
|
|
107
|
+
* (`indexer.ts` -> `decryptIndexDocForSearch`), which is what lets the token index survive
|
|
108
|
+
* encryption being switched on or off. That decrypt step is a no-op once
|
|
109
|
+
* `TENANT_DATA_ENCRYPTION=no`, so an operator who flips the toggle before running
|
|
110
|
+
* `mercato entities decrypt-database` starts feeding ciphertext into the tokeniser. The tokens
|
|
111
|
+
* that come out are hashes of base64 noise and match nothing, and because a write REPLACES a
|
|
112
|
+
* record's tokens, the good plaintext tokens already in the table would be deleted to make room
|
|
113
|
+
* for them -- turning a recoverable misordering into permanent search loss.
|
|
114
|
+
*
|
|
115
|
+
* Detecting the envelope by shape lets the write skip those fields and leave what is already
|
|
116
|
+
* indexed alone. `guard` gates that detection; see {@link shouldGuardCiphertext} for why it is not
|
|
117
|
+
* unconditional.
|
|
118
|
+
*/
|
|
119
|
+
function ciphertextFieldsOf(
|
|
120
|
+
doc: Record<string, unknown> | null | undefined,
|
|
121
|
+
guard: boolean,
|
|
122
|
+
): Set<string> {
|
|
123
|
+
const fields = new Set<string>()
|
|
124
|
+
if (!guard || !doc) return fields
|
|
125
|
+
for (const [field, value] of Object.entries(doc)) {
|
|
126
|
+
const values = collectTextValues(value)
|
|
127
|
+
if (values.length && values.some((text) => looksLikeEncryptedPayload(text))) fields.add(field)
|
|
128
|
+
}
|
|
129
|
+
return fields
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const warnedCiphertextEntities = new Set<string>()
|
|
133
|
+
|
|
134
|
+
function warnCiphertextSkipped(entityType: string, tenantId: string | null, fields: Set<string>): void {
|
|
135
|
+
if (!fields.size) return
|
|
136
|
+
// Once per entity type per tenant per process: a full reindex would otherwise emit this per
|
|
137
|
+
// record, while keying on the entity type alone would let the first affected tenant in a shared
|
|
138
|
+
// process consume the one warning every other tenant's operator needed.
|
|
139
|
+
const key = `${entityType}|${tenantId ?? ''}`
|
|
140
|
+
if (warnedCiphertextEntities.has(key)) return
|
|
141
|
+
warnedCiphertextEntities.add(key)
|
|
142
|
+
logger.warn(
|
|
143
|
+
'Search indexing skipped ciphertext fields and preserved their existing tokens. '
|
|
144
|
+
+ 'This means TENANT_DATA_ENCRYPTION was switched off while encrypted data was still at rest. '
|
|
145
|
+
+ 'Run `mercato entities decrypt-database` and reindex; until then these fields are not searchable.',
|
|
146
|
+
{ entityType, tenantId, fields: Array.from(fields).sort((left, right) => left.localeCompare(right)) },
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Test seam: both the warning above and the KMS behind the guard are once-per-process. */
|
|
151
|
+
export function resetCiphertextGuardState(): void {
|
|
152
|
+
warnedCiphertextEntities.clear()
|
|
153
|
+
guardKmsService = null
|
|
154
|
+
}
|
|
155
|
+
|
|
71
156
|
function shouldIndexField(
|
|
72
157
|
field: string,
|
|
73
158
|
value: unknown,
|
|
@@ -97,9 +182,12 @@ export function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[
|
|
|
97
182
|
const limits = resolveSearchTokenLimits(config)
|
|
98
183
|
const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY
|
|
99
184
|
const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY
|
|
185
|
+
const ciphertextFields = ciphertextFieldsOf(params.doc, params.guardCiphertext ?? shouldGuardCiphertext())
|
|
186
|
+
warnCiphertextSkipped(params.entityType, scope.tenantId, ciphertextFields)
|
|
100
187
|
|
|
101
188
|
for (const [field, rawValue] of Object.entries(params.doc)) {
|
|
102
189
|
if (tokens.length >= recordLimit) break
|
|
190
|
+
if (ciphertextFields.has(field)) continue
|
|
103
191
|
if (!shouldIndexField(field, rawValue, config, params.entityType)) continue
|
|
104
192
|
const values = collectTextValues(rawValue)
|
|
105
193
|
const seen = new Set<string>()
|
|
@@ -149,11 +237,18 @@ export function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[
|
|
|
149
237
|
return tokens
|
|
150
238
|
}
|
|
151
239
|
|
|
152
|
-
function buildFieldPairs(
|
|
240
|
+
function buildFieldPairs(
|
|
241
|
+
recordId: string,
|
|
242
|
+
doc?: Record<string, unknown> | null,
|
|
243
|
+
skipFields?: Set<string>,
|
|
244
|
+
): EntityFieldPair[] {
|
|
153
245
|
if (!doc) return []
|
|
154
246
|
const pairs: EntityFieldPair[] = []
|
|
155
247
|
const dedupe = new Set<string>()
|
|
156
248
|
for (const field of Object.keys(doc)) {
|
|
249
|
+
// The delete below is scoped to these pairs, so omitting a field here is what preserves the
|
|
250
|
+
// tokens already stored for it rather than merely declining to write new ones.
|
|
251
|
+
if (skipFields?.has(field)) continue
|
|
157
252
|
const key = `${recordId}|${field}`
|
|
158
253
|
if (dedupe.has(key)) continue
|
|
159
254
|
dedupe.add(key)
|
|
@@ -221,12 +316,22 @@ export async function replaceSearchTokensForRecord(
|
|
|
221
316
|
params: BuildTokenOptions,
|
|
222
317
|
options?: { trx?: SearchTokenExecutor },
|
|
223
318
|
): Promise<void> {
|
|
224
|
-
const
|
|
319
|
+
const guardCiphertext = params.guardCiphertext ?? shouldGuardCiphertext()
|
|
320
|
+
const rows = buildSearchTokenRows({ ...params, guardCiphertext })
|
|
225
321
|
const config = params.config ?? resolveSearchConfig()
|
|
226
322
|
if (!config.enabled) return
|
|
227
323
|
const organizationId = params.organizationId ?? null
|
|
228
324
|
const tenantId = params.tenantId ?? null
|
|
229
|
-
const
|
|
325
|
+
const ciphertextFields = ciphertextFieldsOf(params.doc, guardCiphertext)
|
|
326
|
+
const fieldPairs = buildFieldPairs(String(params.recordId), params.doc, ciphertextFields)
|
|
327
|
+
|
|
328
|
+
// An empty pair list normally means the document is gone, and the delete below then purges the
|
|
329
|
+
// record wholesale. It can now also mean every field was skipped as ciphertext, where a purge
|
|
330
|
+
// would destroy precisely the tokens the skip exists to protect. Distinguish the two.
|
|
331
|
+
if (params.doc && ciphertextFields.size && !fieldPairs.length) {
|
|
332
|
+
debug('record.preserve-ciphertext', { entityType: params.entityType, recordId: params.recordId })
|
|
333
|
+
return
|
|
334
|
+
}
|
|
230
335
|
|
|
231
336
|
// Same comparison #5402 gave the batch path, over the scope this path actually writes: the
|
|
232
337
|
// delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison
|
|
@@ -325,13 +430,30 @@ export async function deleteSearchTokensForRecord(
|
|
|
325
430
|
|
|
326
431
|
export async function replaceSearchTokensForBatch(
|
|
327
432
|
db: Kysely<any>,
|
|
328
|
-
|
|
433
|
+
allPayloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>
|
|
329
434
|
): Promise<void> {
|
|
330
|
-
if (!
|
|
435
|
+
if (!allPayloads.length) return
|
|
331
436
|
const config = resolveSearchConfig()
|
|
332
437
|
if (!config.enabled) return
|
|
333
438
|
|
|
334
|
-
|
|
439
|
+
// A record carrying ciphertext drops out of the batch entirely, rather than being rewritten
|
|
440
|
+
// without its encrypted fields. This path deletes by `entity_id` -- it cannot express "replace
|
|
441
|
+
// these fields and leave those alone" the way the per-record path can -- so partial handling
|
|
442
|
+
// here would still delete the tokens we are trying to protect. Skipping the record leaves every
|
|
443
|
+
// one of its tokens, encrypted-field and plaintext-field alike, exactly as it was. The state is
|
|
444
|
+
// transient by construction: `decrypt-database` followed by a reindex rebuilds all of it.
|
|
445
|
+
const guardCiphertext = shouldGuardCiphertext()
|
|
446
|
+
const preservedRecordIds = new Set<string>()
|
|
447
|
+
const payloads = allPayloads.filter((payload) => {
|
|
448
|
+
const ciphertextFields = ciphertextFieldsOf(payload.doc, guardCiphertext)
|
|
449
|
+
if (!ciphertextFields.size) return true
|
|
450
|
+
warnCiphertextSkipped(payload.entityType, payload.tenantId ?? null, ciphertextFields)
|
|
451
|
+
preservedRecordIds.add(String(payload.recordId))
|
|
452
|
+
return false
|
|
453
|
+
})
|
|
454
|
+
if (!payloads.length) return
|
|
455
|
+
|
|
456
|
+
const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config, guardCiphertext }))
|
|
335
457
|
if (!rows.length) {
|
|
336
458
|
const entityType = payloads[0]?.entityType
|
|
337
459
|
if (!entityType) return
|
|
@@ -438,6 +560,7 @@ export async function replaceSearchTokensForBatch(
|
|
|
438
560
|
entityType: payloads[0].entityType,
|
|
439
561
|
recordCount: payloads.length,
|
|
440
562
|
changedCount: changedRecordKeys.size,
|
|
563
|
+
preservedCiphertextRecordCount: preservedRecordIds.size,
|
|
441
564
|
})
|
|
442
565
|
if (!changedRecordKeys.size) return
|
|
443
566
|
|