@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.
@@ -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
- constructor(tenantId: string) {
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
- `no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or ` +
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
- async function resolveCredentialsDek(scope: IntegrationScope): Promise<string> {
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: 'Payment status polling failed',
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(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {
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 rows = buildSearchTokenRows(params)
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 fieldPairs = buildFieldPairs(String(params.recordId), params.doc)
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
- payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>
433
+ allPayloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>
329
434
  ): Promise<void> {
330
- if (!payloads.length) return
435
+ if (!allPayloads.length) return
331
436
  const config = resolveSearchConfig()
332
437
  if (!config.enabled) return
333
438
 
334
- const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))
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