@open-mercato/core 0.10.1-develop.7787.1.96c7cfc66f → 0.10.1-develop.7793.1.9e0c43fdde
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/modules/audit_logs/api/audit-logs/actions/redo/route.js +4 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +3 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +3 -3
- package/dist/modules/entities/cli.js +146 -90
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +14 -1
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +13 -4
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/lib/replacementOrderUndo.js +50 -0
- package/dist/modules/warranty_claims/lib/replacementOrderUndo.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +4 -4
- package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +3 -6
- package/src/modules/entities/cli.ts +193 -96
- package/src/modules/sales/commands/documents.ts +15 -1
- package/src/modules/warranty_claims/commands/claims.ts +16 -3
- package/src/modules/warranty_claims/lib/replacementOrderUndo.ts +67 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
4
|
+
}
|
|
5
|
+
async function isInitialReplacementOrderApproval(ctx, scope, orderId, savedVersion, currentVersion) {
|
|
6
|
+
let logs;
|
|
7
|
+
try {
|
|
8
|
+
logs = ctx.container.resolve("actionLogService");
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (!logs) return false;
|
|
13
|
+
const history = await logs.list({
|
|
14
|
+
...scope,
|
|
15
|
+
resourceKind: "sales.order",
|
|
16
|
+
resourceId: orderId,
|
|
17
|
+
includeRelated: true,
|
|
18
|
+
after: new Date(savedVersion),
|
|
19
|
+
pageSize: 3
|
|
20
|
+
});
|
|
21
|
+
const entries = history.items;
|
|
22
|
+
if (history.total > 3 || entries.length !== history.total) return false;
|
|
23
|
+
const edits = entries.filter((entry2) => !(entry2.commandId === "sales.orders.create" && entry2.resourceKind === "sales.order" && entry2.resourceId === orderId && entry2.tenantId === scope.tenantId && entry2.organizationId === scope.organizationId));
|
|
24
|
+
if (edits.length !== 1) return false;
|
|
25
|
+
const entry = edits[0];
|
|
26
|
+
if (entry.commandId !== "sales.orders.update" || entry.executionState !== "done") return false;
|
|
27
|
+
if (entry.tenantId !== scope.tenantId || entry.organizationId !== scope.organizationId || entry.resourceKind !== "sales.order" || entry.resourceId !== orderId) return false;
|
|
28
|
+
const before = record(entry.snapshotBefore);
|
|
29
|
+
const after = record(entry.snapshotAfter);
|
|
30
|
+
const beforeOrder = record(before?.order);
|
|
31
|
+
const afterOrder = record(after?.order);
|
|
32
|
+
if (!beforeOrder || !afterOrder || beforeOrder.id !== orderId || afterOrder.id !== orderId) return false;
|
|
33
|
+
if (beforeOrder.updatedAt !== savedVersion || afterOrder.updatedAt !== currentVersion) return false;
|
|
34
|
+
if (beforeOrder.status !== null || beforeOrder.statusEntryId !== null || afterOrder.status !== "pending_approval") return false;
|
|
35
|
+
const comparableOrder = (order) => Object.fromEntries(
|
|
36
|
+
Object.entries(order).filter(([key]) => !["status", "statusEntryId", "updatedAt"].includes(key))
|
|
37
|
+
);
|
|
38
|
+
if (!isDeepStrictEqual(comparableOrder(beforeOrder), comparableOrder(afterOrder))) return false;
|
|
39
|
+
const input = record(record(entry.commandPayload)?.__redoInput);
|
|
40
|
+
if (!input || input.id !== orderId || typeof input.statusEntryId !== "string" || input.statusEntryId !== afterOrder.statusEntryId || Object.keys(input).some((key) => !["id", "statusEntryId"].includes(key))) return false;
|
|
41
|
+
const beforeNotes = before?.notes;
|
|
42
|
+
const afterNotes = after?.notes;
|
|
43
|
+
if (!Array.isArray(beforeNotes) || !Array.isArray(afterNotes) || afterNotes.length !== beforeNotes.length + 1) return false;
|
|
44
|
+
if (!beforeNotes.every((note) => afterNotes.some((candidate) => isDeepStrictEqual(note, candidate)))) return false;
|
|
45
|
+
return ["lines", "adjustments", "addresses", "tags", "shipments", "payments"].every((key) => Array.isArray(before?.[key]) && isDeepStrictEqual(before?.[key], after?.[key]));
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
isInitialReplacementOrderApproval
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=replacementOrderUndo.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/warranty_claims/lib/replacementOrderUndo.ts"],
|
|
4
|
+
"sourcesContent": ["import { isDeepStrictEqual } from 'node:util'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'\nimport type { WarrantyClaimScope } from '../commands/shared'\n\ntype OrderAuditEntry = NonNullable<Awaited<ReturnType<ActionLogService['findById']>>>\n\nfunction record(value: unknown): Record<string, unknown> | null {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n ? value as Record<string, unknown>\n : null\n}\n\nexport async function isInitialReplacementOrderApproval(\n ctx: CommandRuntimeContext,\n scope: WarrantyClaimScope,\n orderId: string,\n savedVersion: string,\n currentVersion: string,\n): Promise<boolean> {\n let logs: ActionLogService\n try {\n logs = ctx.container.resolve<ActionLogService>('actionLogService')\n } catch {\n return false\n }\n if (!logs) return false\n const history = await logs.list({\n ...scope,\n resourceKind: 'sales.order',\n resourceId: orderId,\n includeRelated: true,\n after: new Date(savedVersion),\n pageSize: 3,\n })\n const entries: OrderAuditEntry[] = history.items\n if (history.total > 3 || entries.length !== history.total) return false\n const edits = entries.filter((entry) => !(entry.commandId === 'sales.orders.create'\n && entry.resourceKind === 'sales.order' && entry.resourceId === orderId\n && entry.tenantId === scope.tenantId && entry.organizationId === scope.organizationId))\n if (edits.length !== 1) return false\n const entry = edits[0]\n if (entry.commandId !== 'sales.orders.update' || entry.executionState !== 'done') return false\n if (entry.tenantId !== scope.tenantId || entry.organizationId !== scope.organizationId\n || entry.resourceKind !== 'sales.order' || entry.resourceId !== orderId) return false\n const before = record(entry.snapshotBefore)\n const after = record(entry.snapshotAfter)\n const beforeOrder = record(before?.order)\n const afterOrder = record(after?.order)\n if (!beforeOrder || !afterOrder || beforeOrder.id !== orderId || afterOrder.id !== orderId) return false\n if (beforeOrder.updatedAt !== savedVersion || afterOrder.updatedAt !== currentVersion) return false\n if (beforeOrder.status !== null || beforeOrder.statusEntryId !== null || afterOrder.status !== 'pending_approval') return false\n const comparableOrder = (order: Record<string, unknown>) => Object.fromEntries(\n Object.entries(order).filter(([key]) => !['status', 'statusEntryId', 'updatedAt'].includes(key)),\n )\n if (!isDeepStrictEqual(comparableOrder(beforeOrder), comparableOrder(afterOrder))) return false\n const input = record(record(entry.commandPayload)?.__redoInput)\n if (!input || input.id !== orderId || typeof input.statusEntryId !== 'string'\n || input.statusEntryId !== afterOrder.statusEntryId\n || Object.keys(input).some((key) => !['id', 'statusEntryId'].includes(key))) return false\n const beforeNotes = before?.notes\n const afterNotes = after?.notes\n if (!Array.isArray(beforeNotes) || !Array.isArray(afterNotes) || afterNotes.length !== beforeNotes.length + 1) return false\n if (!beforeNotes.every((note) => afterNotes.some((candidate) => isDeepStrictEqual(note, candidate)))) return false\n return ['lines', 'adjustments', 'addresses', 'tags', 'shipments', 'payments']\n .every((key) => Array.isArray(before?.[key]) && isDeepStrictEqual(before?.[key], after?.[key]))\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,yBAAyB;AAOlC,SAAS,OAAO,OAAgD;AAC9D,SAAO,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,IACtE,QACA;AACN;AAEA,eAAsB,kCACpB,KACA,OACA,SACA,cACA,gBACkB;AAClB,MAAI;AACJ,MAAI;AACF,WAAO,IAAI,UAAU,QAA0B,kBAAkB;AAAA,EACnE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,UAAU,MAAM,KAAK,KAAK;AAAA,IAC9B,GAAG;AAAA,IACH,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,OAAO,IAAI,KAAK,YAAY;AAAA,IAC5B,UAAU;AAAA,EACZ,CAAC;AACD,QAAM,UAA6B,QAAQ;AAC3C,MAAI,QAAQ,QAAQ,KAAK,QAAQ,WAAW,QAAQ,MAAO,QAAO;AAClE,QAAM,QAAQ,QAAQ,OAAO,CAACA,WAAU,EAAEA,OAAM,cAAc,yBACzDA,OAAM,iBAAiB,iBAAiBA,OAAM,eAAe,WAC7DA,OAAM,aAAa,MAAM,YAAYA,OAAM,mBAAmB,MAAM,eAAe;AACxF,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,QAAQ,MAAM,CAAC;AACrB,MAAI,MAAM,cAAc,yBAAyB,MAAM,mBAAmB,OAAQ,QAAO;AACzF,MAAI,MAAM,aAAa,MAAM,YAAY,MAAM,mBAAmB,MAAM,kBACnE,MAAM,iBAAiB,iBAAiB,MAAM,eAAe,QAAS,QAAO;AAClF,QAAM,SAAS,OAAO,MAAM,cAAc;AAC1C,QAAM,QAAQ,OAAO,MAAM,aAAa;AACxC,QAAM,cAAc,OAAO,QAAQ,KAAK;AACxC,QAAM,aAAa,OAAO,OAAO,KAAK;AACtC,MAAI,CAAC,eAAe,CAAC,cAAc,YAAY,OAAO,WAAW,WAAW,OAAO,QAAS,QAAO;AACnG,MAAI,YAAY,cAAc,gBAAgB,WAAW,cAAc,eAAgB,QAAO;AAC9F,MAAI,YAAY,WAAW,QAAQ,YAAY,kBAAkB,QAAQ,WAAW,WAAW,mBAAoB,QAAO;AAC1H,QAAM,kBAAkB,CAAC,UAAmC,OAAO;AAAA,IACjE,OAAO,QAAQ,KAAK,EAAE,OAAO,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,UAAU,iBAAiB,WAAW,EAAE,SAAS,GAAG,CAAC;AAAA,EACjG;AACA,MAAI,CAAC,kBAAkB,gBAAgB,WAAW,GAAG,gBAAgB,UAAU,CAAC,EAAG,QAAO;AAC1F,QAAM,QAAQ,OAAO,OAAO,MAAM,cAAc,GAAG,WAAW;AAC9D,MAAI,CAAC,SAAS,MAAM,OAAO,WAAW,OAAO,MAAM,kBAAkB,YAChE,MAAM,kBAAkB,WAAW,iBACnC,OAAO,KAAK,KAAK,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC,MAAM,eAAe,EAAE,SAAS,GAAG,CAAC,EAAG,QAAO;AACtF,QAAM,cAAc,QAAQ;AAC5B,QAAM,aAAa,OAAO;AAC1B,MAAI,CAAC,MAAM,QAAQ,WAAW,KAAK,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,YAAY,SAAS,EAAG,QAAO;AACtH,MAAI,CAAC,YAAY,MAAM,CAAC,SAAS,WAAW,KAAK,CAAC,cAAc,kBAAkB,MAAM,SAAS,CAAC,CAAC,EAAG,QAAO;AAC7G,SAAO,CAAC,SAAS,eAAe,aAAa,QAAQ,aAAa,UAAU,EACzE,MAAM,CAAC,QAAQ,MAAM,QAAQ,SAAS,GAAG,CAAC,KAAK,kBAAkB,SAAS,GAAG,GAAG,QAAQ,GAAG,CAAC,CAAC;AAClG;",
|
|
6
|
+
"names": ["entry"]
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.4.3"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7793.1.9e0c43fdde",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -5,7 +5,8 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
5
5
|
import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
6
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
7
|
import { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
|
|
8
|
-
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
8
|
+
import { isCrudHttpError, translateCrudErrorBody } from '@open-mercato/shared/lib/crud/errors'
|
|
9
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
9
10
|
import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
|
|
10
11
|
import type { CommandRuntimeContext, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
|
|
11
12
|
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
@@ -197,11 +198,10 @@ export async function POST(req: Request) {
|
|
|
197
198
|
}
|
|
198
199
|
return response
|
|
199
200
|
} catch (err) {
|
|
200
|
-
// Same contract as undo: a domain refusal carries an already-localized body the
|
|
201
|
-
// operator can act on, so surface it instead of a generic message.
|
|
202
201
|
if (isCrudHttpError(err)) {
|
|
203
202
|
logger.warn('Redo rejected', { err })
|
|
204
|
-
|
|
203
|
+
const { translate } = await resolveTranslations()
|
|
204
|
+
return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status })
|
|
205
205
|
}
|
|
206
206
|
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
207
207
|
if (interceptorRejection) {
|
|
@@ -5,7 +5,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
5
5
|
import { resolveFeatureCheckContext, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
6
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
7
|
import { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'
|
|
8
|
-
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
8
|
+
import { isCrudHttpError, translateCrudErrorBody } from '@open-mercato/shared/lib/crud/errors'
|
|
9
9
|
import { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
|
|
10
10
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
11
11
|
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
@@ -158,13 +158,10 @@ export async function POST(req: Request) {
|
|
|
158
158
|
await commandBus.undo(undoToken, ctx)
|
|
159
159
|
return NextResponse.json({ ok: true, logId: target.id })
|
|
160
160
|
} catch (err) {
|
|
161
|
-
// A command can refuse an undo for a domain reason the operator can act on — e.g.
|
|
162
|
-
// reverting a role grant would drop a tenant below a protected role's active-holder
|
|
163
|
-
// floor. Those arrive as CrudHttpError with an already-localized body, so pass them
|
|
164
|
-
// through verbatim instead of flattening every failure to a generic message.
|
|
165
161
|
if (isCrudHttpError(err)) {
|
|
166
162
|
logger.warn('Undo rejected', { err, logId: target.id })
|
|
167
|
-
|
|
163
|
+
const { translate } = await resolveTranslations()
|
|
164
|
+
return NextResponse.json(translateCrudErrorBody(err.body, translate), { status: err.status })
|
|
168
165
|
}
|
|
169
166
|
// A beforeUndo interceptor that blocked with an explicit status is a deliberate business
|
|
170
167
|
// rejection, not an undo failure — surface its status and message (issue #5045).
|
|
@@ -330,6 +330,12 @@ function normalizeKeyInput(value: string): string {
|
|
|
330
330
|
return value.trim().replace(/(?:^['"]|['"]$)/g, '')
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
const OLD_ENCRYPTION_KEY_ENV = 'TENANT_DATA_ENCRYPTION_OLD_KEY'
|
|
334
|
+
|
|
335
|
+
function failCli(message: string): never {
|
|
336
|
+
throw new Error(message)
|
|
337
|
+
}
|
|
338
|
+
|
|
333
339
|
class DerivedKeyKmsService implements KmsService {
|
|
334
340
|
private root: Buffer
|
|
335
341
|
constructor(secret: string) {
|
|
@@ -357,11 +363,6 @@ class DerivedKeyKmsService implements KmsService {
|
|
|
357
363
|
}
|
|
358
364
|
}
|
|
359
365
|
|
|
360
|
-
function fingerprintDek(dek: TenantDek | null): string | null {
|
|
361
|
-
if (!dek?.key) return null
|
|
362
|
-
return crypto.createHash('sha256').update(dek.key).digest('hex').slice(0, 12)
|
|
363
|
-
}
|
|
364
|
-
|
|
365
366
|
function decryptWithOldKey(
|
|
366
367
|
payload: string,
|
|
367
368
|
dek: TenantDek | null,
|
|
@@ -405,22 +406,90 @@ interface EncryptionMapMeta {
|
|
|
405
406
|
tenantId: string
|
|
406
407
|
}
|
|
407
408
|
|
|
408
|
-
|
|
409
|
+
// A map is only safe to act on when every mapped field resolves to a real column. The
|
|
410
|
+
// distinction that matters for the exit code is whether skipping it could leave ciphertext
|
|
411
|
+
// behind: a map that declares no fields, or that this command does not own, cannot — while a map
|
|
412
|
+
// whose fields exist on paper but cannot be located is a reason to refuse the run.
|
|
413
|
+
type EncryptionMapResolution =
|
|
414
|
+
| { status: 'ready'; mapMeta: EncryptionMapMeta }
|
|
415
|
+
| { status: 'nothing-to-process'; reason: string }
|
|
416
|
+
| { status: 'unresolvable'; reason: string }
|
|
417
|
+
|
|
418
|
+
function classifyEncryptionMap(
|
|
409
419
|
map: EncryptionMap,
|
|
410
420
|
metaByEntityId: Map<string, any>,
|
|
411
|
-
|
|
412
|
-
): EncryptionMapMeta | null {
|
|
421
|
+
): EncryptionMapResolution {
|
|
413
422
|
const entityId = String(map.entityId)
|
|
423
|
+
const fields = Array.isArray(map.fieldsJson) ? map.fieldsJson : []
|
|
424
|
+
if (!fields.length) {
|
|
425
|
+
return { status: 'nothing-to-process', reason: 'the map declares no encrypted fields' }
|
|
426
|
+
}
|
|
427
|
+
// The "not this command's business" checks come before the consistency checks: a tenantless map
|
|
428
|
+
// is out of scope whether or not its entity still resolves, so testing scope first keeps an
|
|
429
|
+
// out-of-scope row from being reported as a blocking inconsistency.
|
|
430
|
+
const tenantId = map.tenantId ? String(map.tenantId) : null
|
|
431
|
+
if (!tenantId) {
|
|
432
|
+
// Documented behaviour (apps/docs/docs/user-guide/encryption.mdx, "Backfilling system-scoped
|
|
433
|
+
// records"): these commands walk encryption_maps and skip every row without a tenant, because
|
|
434
|
+
// a tenant key cannot open ciphertext sealed under a system key. That is
|
|
435
|
+
// `backfill-system-encryption`'s job, not this command's.
|
|
436
|
+
return {
|
|
437
|
+
status: 'nothing-to-process',
|
|
438
|
+
reason: 'the map has no tenant_id — use "mercato entities backfill-system-encryption" for system-scoped records',
|
|
439
|
+
}
|
|
440
|
+
}
|
|
414
441
|
const meta = metaByEntityId.get(entityId)
|
|
415
442
|
if (!meta) {
|
|
416
|
-
|
|
417
|
-
|
|
443
|
+
return {
|
|
444
|
+
status: 'unresolvable',
|
|
445
|
+
reason: 'entity metadata is not registered in this app — is the module that owns it still enabled?',
|
|
446
|
+
}
|
|
418
447
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
448
|
+
if (!meta.tableName) {
|
|
449
|
+
return { status: 'unresolvable', reason: 'table metadata is missing' }
|
|
450
|
+
}
|
|
451
|
+
for (const rule of fields) {
|
|
452
|
+
if (!resolveProperty(meta, rule.field).columnName) {
|
|
453
|
+
return { status: 'unresolvable', reason: `mapped field "${rule.field}" was not found` }
|
|
454
|
+
}
|
|
455
|
+
if (rule.hashField && !resolveProperty(meta, rule.hashField).columnName) {
|
|
456
|
+
return { status: 'unresolvable', reason: `hash field "${rule.hashField}" was not found` }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { status: 'ready', mapMeta: { entityId, meta, fields, tenantId } }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function resolveMapsOrFail(
|
|
463
|
+
maps: EncryptionMap[],
|
|
464
|
+
metaByEntityId: ReturnType<typeof buildEntityMetaRegistry>,
|
|
465
|
+
): Array<{ map: EncryptionMap; mapMeta: EncryptionMapMeta }> {
|
|
466
|
+
const resolved: Array<{ map: EncryptionMap; mapMeta: EncryptionMapMeta }> = []
|
|
467
|
+
const unresolvable: string[] = []
|
|
468
|
+
for (const map of maps) {
|
|
469
|
+
const resolution = classifyEncryptionMap(map, metaByEntityId)
|
|
470
|
+
if (resolution.status === 'ready') {
|
|
471
|
+
resolved.push({ map, mapMeta: resolution.mapMeta })
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
if (resolution.status === 'nothing-to-process') {
|
|
475
|
+
console.warn(`Skipping ${String(map.entityId)}: ${resolution.reason}.`)
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
478
|
+
unresolvable.push(` ${String(map.entityId)} (encryption_maps.id=${String(map.id)}): ${resolution.reason}`)
|
|
479
|
+
}
|
|
480
|
+
// Report every offending map at once: the operator has to clean up all of them before the
|
|
481
|
+
// command can run, and failing on the first one would make that an N-run guessing game.
|
|
482
|
+
if (unresolvable.length) {
|
|
483
|
+
failCli(
|
|
484
|
+
`${unresolvable.length} encryption map(s) in the selected scope cannot be processed, so this command would have `
|
|
485
|
+
+ 'left their ciphertext untouched while reporting success. No rows were changed.\n'
|
|
486
|
+
+ unresolvable.join('\n')
|
|
487
|
+
+ '\nResolve each one before re-running: re-enable the module that owns the entity, or — once the entity\'s data '
|
|
488
|
+
+ 'is confirmed gone — soft-delete the stale map row '
|
|
489
|
+
+ '(UPDATE encryption_maps SET deleted_at = now() WHERE id = \'…\').',
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
return resolved
|
|
424
493
|
}
|
|
425
494
|
|
|
426
495
|
function formatValueForColumn(prop: any, value: unknown): unknown {
|
|
@@ -438,33 +507,46 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
438
507
|
const args = parseArgs(rest)
|
|
439
508
|
const tenantIdArg = (args.tenant as string) || (args.tenantId as string) || null
|
|
440
509
|
const organizationIdArg = (args.org as string) || (args.organization as string) || (args.organizationId as string) || null
|
|
441
|
-
const
|
|
510
|
+
const oldKeyArgument = (args['old-key'] as string) || (args.oldKey as string) || null
|
|
511
|
+
const oldKeyEnvironment = process.env[OLD_ENCRYPTION_KEY_ENV]
|
|
512
|
+
? normalizeKeyInput(process.env[OLD_ENCRYPTION_KEY_ENV]!)
|
|
513
|
+
: null
|
|
514
|
+
if (
|
|
515
|
+
oldKeyArgument
|
|
516
|
+
&& oldKeyEnvironment
|
|
517
|
+
&& normalizeKeyInput(oldKeyArgument) !== oldKeyEnvironment
|
|
518
|
+
) {
|
|
519
|
+
failCli(`--old-key and ${OLD_ENCRYPTION_KEY_ENV} contain different values. Remove --old-key and retry.`)
|
|
520
|
+
}
|
|
521
|
+
if (oldKeyArgument) {
|
|
522
|
+
console.warn(
|
|
523
|
+
`Passing key material through --old-key can expose it in shell history and process listings. Set ${OLD_ENCRYPTION_KEY_ENV} through your secret manager instead.`,
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
const oldKey = oldKeyEnvironment || oldKeyArgument
|
|
442
527
|
const dryRun = Boolean(args['dry-run'] || args.dry)
|
|
443
528
|
const debug = Boolean(args.debug)
|
|
444
529
|
const rotate = Boolean(oldKey)
|
|
445
530
|
if (rotate && !tenantIdArg) {
|
|
446
531
|
console.warn(
|
|
447
|
-
'⚠️ Rotating with
|
|
532
|
+
'⚠️ Rotating with one previous key across all tenants. A single previous key should normally target one tenant; consider --tenant.',
|
|
448
533
|
)
|
|
449
534
|
}
|
|
450
535
|
if (!isTenantDataEncryptionEnabled()) {
|
|
451
|
-
|
|
452
|
-
return
|
|
536
|
+
failCli('TENANT_DATA_ENCRYPTION is disabled; aborting.')
|
|
453
537
|
}
|
|
454
538
|
|
|
455
539
|
const { resolve } = await createRequestContainer()
|
|
456
540
|
const em = resolve('em') as any
|
|
457
541
|
const conn: any = em?.getConnection?.()
|
|
458
542
|
if (!conn || typeof conn.execute !== 'function') {
|
|
459
|
-
|
|
460
|
-
return
|
|
543
|
+
failCli('Unable to access raw connection; aborting.')
|
|
461
544
|
}
|
|
462
545
|
|
|
463
546
|
const encryptionService = new TenantDataEncryptionService(em as any, { kms: createKmsService() })
|
|
464
547
|
const oldKms = rotate && oldKey ? new DerivedKeyKmsService(oldKey) : null
|
|
465
548
|
if (!encryptionService.isEnabled()) {
|
|
466
|
-
|
|
467
|
-
return
|
|
549
|
+
failCli('Encryption service is not enabled (KMS unhealthy or no DEK). Aborting.')
|
|
468
550
|
}
|
|
469
551
|
|
|
470
552
|
if (debug) {
|
|
@@ -474,18 +556,6 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
474
556
|
tenantId: tenantIdArg ?? null,
|
|
475
557
|
organizationId: organizationIdArg ?? null,
|
|
476
558
|
})
|
|
477
|
-
if (tenantIdArg) {
|
|
478
|
-
const [oldDek, newDek] = await Promise.all([
|
|
479
|
-
oldKms?.getTenantDek(tenantIdArg) ?? Promise.resolve(null),
|
|
480
|
-
encryptionService.getDek(tenantIdArg),
|
|
481
|
-
])
|
|
482
|
-
console.log('[rotate-encryption-key] dek fingerprints', {
|
|
483
|
-
oldKey: fingerprintDek(oldDek),
|
|
484
|
-
currentKey: fingerprintDek(newDek),
|
|
485
|
-
})
|
|
486
|
-
} else {
|
|
487
|
-
console.log('[rotate-encryption-key] dek fingerprints skipped (no tenantId)')
|
|
488
|
-
}
|
|
489
559
|
}
|
|
490
560
|
|
|
491
561
|
const metaByEntityId = buildEntityMetaRegistry(em)
|
|
@@ -502,6 +572,7 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
502
572
|
)
|
|
503
573
|
return false
|
|
504
574
|
})
|
|
575
|
+
const resolvedMaps = resolveMapsOrFail(maps, metaByEntityId)
|
|
505
576
|
const customDocuments = await rotateCustomEntityDocuments(em, {
|
|
506
577
|
encryptionService,
|
|
507
578
|
oldKms,
|
|
@@ -516,7 +587,6 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
516
587
|
if (!customDocuments) console.log('No encryption maps found for the selected scope.')
|
|
517
588
|
return
|
|
518
589
|
}
|
|
519
|
-
|
|
520
590
|
const resolveScopes = async (tenantId: string, organizationId: string | null) => {
|
|
521
591
|
if (organizationId) return [{ tenantId, organizationId }]
|
|
522
592
|
const orgs = await em.find(Organization, { tenant: tenantId })
|
|
@@ -530,7 +600,7 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
530
600
|
|
|
531
601
|
const oldDekCache = new Map<string, TenantDek | null>()
|
|
532
602
|
// Rows this run could not rotate because their ciphertext opens under neither
|
|
533
|
-
//
|
|
603
|
+
// the previous nor the current tenant key — surfaced in the closing summary so an
|
|
534
604
|
// operator does not have to grep console.warn output for a batch of thousands.
|
|
535
605
|
const unrecoverableRows: Array<{ entityId: string; field: string; rowId: unknown }> = []
|
|
536
606
|
// A dry run must not provision key material. `encryptEntityPayload` creates and
|
|
@@ -635,7 +705,7 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
635
705
|
if (rule.hashField) delete payload[rule.hashField]
|
|
636
706
|
unrecoverableRows.push({ entityId, field: rule.field, rowId: row[pk] })
|
|
637
707
|
console.warn(
|
|
638
|
-
`Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither
|
|
708
|
+
`Skipping ${entityId}.${rule.field} for row ${row[pk]}: its ciphertext opens under neither the previous nor the current tenant key. Re-run with the key that sealed it.`,
|
|
639
709
|
)
|
|
640
710
|
}
|
|
641
711
|
}
|
|
@@ -693,9 +763,7 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
693
763
|
}
|
|
694
764
|
|
|
695
765
|
let total = 0
|
|
696
|
-
for (const map of
|
|
697
|
-
const mapMeta = resolveMapMeta(map, metaByEntityId, console.warn)
|
|
698
|
-
if (!mapMeta) continue
|
|
766
|
+
for (const { map, mapMeta } of resolvedMaps) {
|
|
699
767
|
const { entityId, meta, fields, tenantId } = mapMeta
|
|
700
768
|
const scopes = await resolveScopes(tenantId, map.organizationId ? String(map.organizationId) : null)
|
|
701
769
|
for (const scope of scopes) {
|
|
@@ -718,7 +786,7 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
718
786
|
const maxListed = 20
|
|
719
787
|
console.log(
|
|
720
788
|
`\n⚠️ ${unrecoverableRows.length} field(s) could not be rotated — their ciphertext opens under neither `
|
|
721
|
-
+ '
|
|
789
|
+
+ 'the previous nor the current tenant key, so they were left untouched. See '
|
|
722
790
|
+ '"Key management" in apps/docs/docs/architecture/data-encryption.mdx for how to resolve these manually.',
|
|
723
791
|
)
|
|
724
792
|
for (const row of unrecoverableRows.slice(0, maxListed)) {
|
|
@@ -727,6 +795,9 @@ const rotateEncryptionKey: ModuleCli = {
|
|
|
727
795
|
if (unrecoverableRows.length > maxListed) {
|
|
728
796
|
console.log(` ... and ${unrecoverableRows.length - maxListed} more`)
|
|
729
797
|
}
|
|
798
|
+
failCli(
|
|
799
|
+
`${unrecoverableRows.length} encrypted field(s) could not be rotated. The command left them untouched and did not complete successfully.`,
|
|
800
|
+
)
|
|
730
801
|
}
|
|
731
802
|
},
|
|
732
803
|
}
|
|
@@ -747,18 +818,15 @@ const decryptDatabase: ModuleCli = {
|
|
|
747
818
|
const debug = Boolean(args.debug)
|
|
748
819
|
|
|
749
820
|
if (!tenantIdArg) {
|
|
750
|
-
|
|
751
|
-
return
|
|
821
|
+
failCli('--tenant <uuid> is required.')
|
|
752
822
|
}
|
|
753
823
|
|
|
754
824
|
if (!checkMode) {
|
|
755
825
|
if (!confirm) {
|
|
756
|
-
|
|
757
|
-
return
|
|
826
|
+
failCli('--confirm <tenantUuid> is required (safety gate). Pass the exact tenant UUID to confirm the operation.')
|
|
758
827
|
}
|
|
759
828
|
if (confirm !== tenantIdArg) {
|
|
760
|
-
|
|
761
|
-
return
|
|
829
|
+
failCli(`--confirm value "${confirm}" does not match --tenant "${tenantIdArg}". Aborting.`)
|
|
762
830
|
}
|
|
763
831
|
}
|
|
764
832
|
|
|
@@ -766,13 +834,11 @@ const decryptDatabase: ModuleCli = {
|
|
|
766
834
|
const em = resolve('em') as any
|
|
767
835
|
const conn: any = em?.getConnection?.()
|
|
768
836
|
if (!conn || typeof conn.execute !== 'function') {
|
|
769
|
-
|
|
770
|
-
return
|
|
837
|
+
failCli('Unable to access raw database connection; aborting.')
|
|
771
838
|
}
|
|
772
839
|
|
|
773
840
|
if (!checkMode && !isTenantDataEncryptionEnabled()) {
|
|
774
|
-
|
|
775
|
-
return
|
|
841
|
+
failCli('TENANT_DATA_ENCRYPTION is disabled; aborting. Data may already be decrypted.')
|
|
776
842
|
}
|
|
777
843
|
|
|
778
844
|
const metaByEntityId = buildEntityMetaRegistry(em)
|
|
@@ -811,6 +877,7 @@ const decryptDatabase: ModuleCli = {
|
|
|
811
877
|
console.log('No active encryption maps found for the selected scope.')
|
|
812
878
|
return
|
|
813
879
|
}
|
|
880
|
+
const resolvedMaps = resolveMapsOrFail(maps, metaByEntityId)
|
|
814
881
|
|
|
815
882
|
const kms = createKmsService()
|
|
816
883
|
const dekCache = new Map<string, TenantDek | null>()
|
|
@@ -827,16 +894,22 @@ const decryptDatabase: ModuleCli = {
|
|
|
827
894
|
console.log(`Active EncryptionMap records for scope: ${maps.length}`)
|
|
828
895
|
let encryptedCandidatesSampled = 0
|
|
829
896
|
let malformedPayloadCountSampled = 0
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
897
|
+
// --check writes nothing, so an unreachable DEK is the diagnosis the operator came for,
|
|
898
|
+
// not a reason to abort mid-inspection. Report every uninspectable entity, inspect the
|
|
899
|
+
// rest, and let the closing gate below make the incomplete run exit non-zero.
|
|
900
|
+
const uninspectableEntityIds: string[] = []
|
|
901
|
+
const failedSampleScopes: string[] = []
|
|
902
|
+
for (const { map, mapMeta } of resolvedMaps) {
|
|
833
903
|
const { entityId, meta, fields, tenantId } = mapMeta
|
|
834
904
|
const dek = await getDek(tenantId).catch(() => null)
|
|
835
|
-
if (!dek)
|
|
905
|
+
if (!dek) {
|
|
906
|
+
console.warn(`⚠ No DEK available for tenant ${tenantId}; cannot inspect ${entityId}.`)
|
|
907
|
+
uninspectableEntityIds.push(entityId)
|
|
908
|
+
continue
|
|
909
|
+
}
|
|
836
910
|
const scopes = await resolveDecryptScopes(tenantId, map.organizationId ? String(map.organizationId) : null)
|
|
837
911
|
const pk = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : 'id'
|
|
838
|
-
const tableName = meta
|
|
839
|
-
if (!tableName) continue
|
|
912
|
+
const tableName = meta.tableName
|
|
840
913
|
const schema = meta?.schema
|
|
841
914
|
const qualifiedTable = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`
|
|
842
915
|
const fieldCols = fields.flatMap((f: any) => {
|
|
@@ -845,10 +918,19 @@ const decryptDatabase: ModuleCli = {
|
|
|
845
918
|
})
|
|
846
919
|
const colList = Array.from(new Set([pk, ...fieldCols]))
|
|
847
920
|
for (const scope of scopes) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
921
|
+
let sampleRows: unknown
|
|
922
|
+
try {
|
|
923
|
+
sampleRows = await conn.execute(
|
|
924
|
+
`SELECT ${colList.map((c: string) => `"${c}"`).join(', ')} FROM ${qualifiedTable} WHERE tenant_id = ? AND organization_id IS NOT DISTINCT FROM ? LIMIT 100`,
|
|
925
|
+
[scope.tenantId, scope.organizationId],
|
|
926
|
+
)
|
|
927
|
+
} catch (error: unknown) {
|
|
928
|
+
const scopeLabel = scope.organizationId ?? 'global'
|
|
929
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
930
|
+
console.warn(`⚠ Could not sample ${entityId} for organization ${scopeLabel}: ${errorMessage}`)
|
|
931
|
+
failedSampleScopes.push(`${entityId} (organization_id=${scopeLabel})`)
|
|
932
|
+
continue
|
|
933
|
+
}
|
|
852
934
|
for (const row of Array.isArray(sampleRows) ? sampleRows : []) {
|
|
853
935
|
let rowHasEncrypted = false
|
|
854
936
|
for (const fieldRule of fields) {
|
|
@@ -877,6 +959,18 @@ const decryptDatabase: ModuleCli = {
|
|
|
877
959
|
console.log(`malformed payloads (sampled): ${malformedPayloadCountSampled}`)
|
|
878
960
|
}
|
|
879
961
|
console.log('not a proof of absence — run full command + rerun --check to confirm')
|
|
962
|
+
if (uninspectableEntityIds.length || failedSampleScopes.length) {
|
|
963
|
+
const unavailableDekSummary = uninspectableEntityIds.length
|
|
964
|
+
? ` No DEK was available for ${uninspectableEntityIds.join(', ')}.`
|
|
965
|
+
: ''
|
|
966
|
+
const failedSampleSummary = failedSampleScopes.length
|
|
967
|
+
? ` Sample queries failed for ${failedSampleScopes.join(', ')}.`
|
|
968
|
+
: ''
|
|
969
|
+
failCli(
|
|
970
|
+
`Encryption check incomplete.${unavailableDekSummary}${failedSampleSummary} `
|
|
971
|
+
+ 'The counts above cover only successfully inspected scopes.',
|
|
972
|
+
)
|
|
973
|
+
}
|
|
880
974
|
return
|
|
881
975
|
}
|
|
882
976
|
|
|
@@ -888,21 +982,21 @@ const decryptDatabase: ModuleCli = {
|
|
|
888
982
|
const malformedByLocation = new Map<string, number>()
|
|
889
983
|
let totalEntitiesProcessed = 0
|
|
890
984
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (
|
|
894
|
-
const
|
|
895
|
-
const dek = await getDek(tenantId)
|
|
985
|
+
const deksByTenantId = new Map<string, TenantDek>()
|
|
986
|
+
for (const { mapMeta } of resolvedMaps) {
|
|
987
|
+
if (deksByTenantId.has(mapMeta.tenantId)) continue
|
|
988
|
+
const dek = await getDek(mapMeta.tenantId)
|
|
896
989
|
if (!dek) {
|
|
897
|
-
|
|
898
|
-
continue
|
|
990
|
+
failCli(`No DEK available for tenant ${mapMeta.tenantId}. No rows were changed.`)
|
|
899
991
|
}
|
|
992
|
+
deksByTenantId.set(mapMeta.tenantId, dek)
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
for (const { map, mapMeta } of resolvedMaps) {
|
|
996
|
+
const { entityId, meta, fields, tenantId } = mapMeta
|
|
997
|
+
const dek = deksByTenantId.get(tenantId)!
|
|
900
998
|
const pk = Array.isArray(meta?.primaryKeys) && meta.primaryKeys.length ? meta.primaryKeys[0] : 'id'
|
|
901
|
-
const tableName = meta
|
|
902
|
-
if (!tableName) {
|
|
903
|
-
console.warn(`Skipping ${entityId}: table name not found.`)
|
|
904
|
-
continue
|
|
905
|
-
}
|
|
999
|
+
const tableName = meta.tableName
|
|
906
1000
|
const schema = meta?.schema
|
|
907
1001
|
const qualifiedTable = schema ? `"${schema}"."${tableName}"` : `"${tableName}"`
|
|
908
1002
|
const fieldCols = fields.flatMap((f: any) => {
|
|
@@ -1035,28 +1129,6 @@ const decryptDatabase: ModuleCli = {
|
|
|
1035
1129
|
}
|
|
1036
1130
|
}
|
|
1037
1131
|
|
|
1038
|
-
if (deactivateMaps && !dryRun) {
|
|
1039
|
-
let deactivateSql = `UPDATE encryption_maps SET is_active = false, deleted_at = now() WHERE tenant_id = ? AND deleted_at IS NULL`
|
|
1040
|
-
const deactivateParams: unknown[] = [tenantIdArg]
|
|
1041
|
-
if (organizationIdArg) {
|
|
1042
|
-
deactivateSql += ` AND (organization_id = ? OR organization_id IS NULL)`
|
|
1043
|
-
deactivateParams.push(organizationIdArg)
|
|
1044
|
-
}
|
|
1045
|
-
if (entityIdArg) {
|
|
1046
|
-
deactivateSql += ` AND entity_id = ?`
|
|
1047
|
-
deactivateParams.push(entityIdArg)
|
|
1048
|
-
}
|
|
1049
|
-
await conn.execute(deactivateSql, deactivateParams)
|
|
1050
|
-
console.warn('⚠ Restart all application replicas — in-process map caches may still be active.')
|
|
1051
|
-
if (isTenantDataEncryptionEnabled()) {
|
|
1052
|
-
console.warn('⚠ Env TENANT_DATA_ENCRYPTION is still true — new writes will be re-encrypted until env is updated and replicas restarted.')
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
if (deactivateMaps && dryRun) {
|
|
1057
|
-
console.log(`[dry-run] Would deactivate ${maps.length} EncryptionMap record(s).`)
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
1132
|
const prefix = dryRun ? '[dry-run] ' : ''
|
|
1061
1133
|
console.log(`\n${prefix}Decryption summary:`)
|
|
1062
1134
|
console.log(` Rows fetched: ${totalRowsFetched}`)
|
|
@@ -1079,6 +1151,31 @@ const decryptDatabase: ModuleCli = {
|
|
|
1079
1151
|
console.log(` ${loc}: ${count}`)
|
|
1080
1152
|
}
|
|
1081
1153
|
}
|
|
1154
|
+
failCli(
|
|
1155
|
+
`${totalMalformedPayloadCount} field value(s) could not be decrypted because their ciphertext was malformed. Encryption maps were left active.`,
|
|
1156
|
+
)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (deactivateMaps && !dryRun) {
|
|
1160
|
+
let deactivateSql = `UPDATE encryption_maps SET is_active = false, deleted_at = now() WHERE tenant_id = ? AND deleted_at IS NULL`
|
|
1161
|
+
const deactivateParams: unknown[] = [tenantIdArg]
|
|
1162
|
+
if (organizationIdArg) {
|
|
1163
|
+
deactivateSql += ` AND (organization_id = ? OR organization_id IS NULL)`
|
|
1164
|
+
deactivateParams.push(organizationIdArg)
|
|
1165
|
+
}
|
|
1166
|
+
if (entityIdArg) {
|
|
1167
|
+
deactivateSql += ` AND entity_id = ?`
|
|
1168
|
+
deactivateParams.push(entityIdArg)
|
|
1169
|
+
}
|
|
1170
|
+
await conn.execute(deactivateSql, deactivateParams)
|
|
1171
|
+
console.warn('⚠ Restart all application replicas — in-process map caches may still be active.')
|
|
1172
|
+
if (isTenantDataEncryptionEnabled()) {
|
|
1173
|
+
console.warn('⚠ Env TENANT_DATA_ENCRYPTION is still true — new writes will be re-encrypted until env is updated and replicas restarted.')
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (deactivateMaps && dryRun) {
|
|
1178
|
+
console.log(`[dry-run] Would deactivate ${maps.length} EncryptionMap record(s).`)
|
|
1082
1179
|
}
|
|
1083
1180
|
|
|
1084
1181
|
if (!dryRun) {
|