@open-mercato/core 0.10.1-develop.7752.1.fd7c06569b → 0.10.1-develop.7756.1.8f5dec1b96
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/helpers/integration/dbFixtures.js +1 -1
- package/dist/helpers/integration/dbFixtures.js.map +1 -1
- package/dist/modules/auth/api/roles/acl/route.js +2 -2
- package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
- package/dist/modules/auth/api/roles/route.js +1 -1
- package/dist/modules/auth/api/roles/route.js.map +2 -2
- package/dist/modules/auth/api/users/acl/route.js +10 -2
- package/dist/modules/auth/api/users/acl/route.js.map +2 -2
- package/dist/modules/auth/commands/acl.js +2 -2
- package/dist/modules/auth/commands/acl.js.map +2 -2
- package/dist/modules/auth/commands/roles.js +1 -1
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -3
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js +2 -2
- package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
- package/dist/modules/auth/lib/grantChecks.js +38 -11
- package/dist/modules/auth/lib/grantChecks.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +2 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
- package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
- package/dist/modules/auth/services/rbacService.js +138 -59
- package/dist/modules/auth/services/rbacService.js.map +3 -3
- package/dist/modules/entities/cli.js +12 -1
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +13 -0
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
- package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
- package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
- package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +179 -11
- package/dist/modules/query_index/lib/engine.js.map +3 -3
- package/package.json +7 -7
- package/src/helpers/integration/dbFixtures.ts +1 -1
- package/src/modules/auth/api/roles/acl/route.ts +2 -2
- package/src/modules/auth/api/roles/route.ts +1 -1
- package/src/modules/auth/api/users/acl/route.ts +10 -2
- package/src/modules/auth/commands/acl.ts +2 -2
- package/src/modules/auth/commands/roles.ts +1 -1
- package/src/modules/auth/commands/users.ts +7 -3
- package/src/modules/auth/data/entities.ts +12 -0
- package/src/modules/auth/lib/executionPrincipal.ts +2 -2
- package/src/modules/auth/lib/grantChecks.ts +115 -11
- package/src/modules/auth/lib/setup-app.ts +2 -2
- package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
- package/src/modules/auth/services/rbacService.ts +161 -76
- package/src/modules/entities/cli.ts +12 -1
- package/src/modules/entities/lib/helpers.ts +17 -0
- package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
- package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
- package/src/modules/query_index/lib/engine.ts +187 -9
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decryptWithAesGcm,
|
|
3
|
+
TenantDataEncryptionError,
|
|
4
|
+
TenantDataEncryptionErrorCode
|
|
5
|
+
} from "@open-mercato/shared/lib/encryption/aes";
|
|
6
|
+
import {
|
|
7
|
+
decryptCustomEntityDocument,
|
|
8
|
+
encryptCustomEntityDocument,
|
|
9
|
+
isCustomEntityDocumentCiphertext
|
|
10
|
+
} from "@open-mercato/shared/lib/encryption/customEntityDoc";
|
|
11
|
+
import { CustomFieldDef } from "../data/entities.js";
|
|
12
|
+
import { loadScopedCustomFieldDefs } from "./scoped-field-defs.js";
|
|
13
|
+
const BATCH_SIZE = 100;
|
|
14
|
+
function holdsCiphertext(value) {
|
|
15
|
+
return Array.isArray(value) ? value.some(isCustomEntityDocumentCiphertext) : isCustomEntityDocumentCiphertext(value);
|
|
16
|
+
}
|
|
17
|
+
function selectHistoricalDefinitions(definitions) {
|
|
18
|
+
const selected = /* @__PURE__ */ new Map();
|
|
19
|
+
const scopeScore = (definition) => (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0);
|
|
20
|
+
const updatedAt = (definition) => new Date(definition.updatedAt).getTime();
|
|
21
|
+
for (const definition of definitions) {
|
|
22
|
+
const previous = selected.get(definition.key);
|
|
23
|
+
if (!previous || scopeScore(definition) > scopeScore(previous) || scopeScore(definition) === scopeScore(previous) && updatedAt(definition) >= updatedAt(previous)) {
|
|
24
|
+
selected.set(definition.key, definition);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return selected;
|
|
28
|
+
}
|
|
29
|
+
async function rotateCustomEntityDocuments(em, options) {
|
|
30
|
+
const definitionScope = [];
|
|
31
|
+
if (options.tenantId) definitionScope.push({ $or: [{ tenantId: options.tenantId }, { tenantId: null }] });
|
|
32
|
+
if (options.organizationId) definitionScope.push({ $or: [{ organizationId: options.organizationId }, { organizationId: null }] });
|
|
33
|
+
const definitions = await em.find(CustomFieldDef, {
|
|
34
|
+
...definitionScope.length ? { $and: definitionScope } : {}
|
|
35
|
+
});
|
|
36
|
+
const entityIds = Array.from(new Set(definitions.map((definition) => definition.entityId)));
|
|
37
|
+
if (!entityIds.length) return 0;
|
|
38
|
+
const connection = em.getConnection();
|
|
39
|
+
const fieldsByScope = /* @__PURE__ */ new Map();
|
|
40
|
+
const oldDeks = /* @__PURE__ */ new Map();
|
|
41
|
+
let cursor = null;
|
|
42
|
+
let updated = 0;
|
|
43
|
+
for (; ; ) {
|
|
44
|
+
const predicates = [`entity_type in (${entityIds.map(() => "?").join(", ")})`];
|
|
45
|
+
const parameters = [...entityIds];
|
|
46
|
+
if (options.tenantId) {
|
|
47
|
+
predicates.push("tenant_id = ?");
|
|
48
|
+
parameters.push(options.tenantId);
|
|
49
|
+
}
|
|
50
|
+
if (options.organizationId) {
|
|
51
|
+
predicates.push("organization_id = ?");
|
|
52
|
+
parameters.push(options.organizationId);
|
|
53
|
+
}
|
|
54
|
+
if (cursor) {
|
|
55
|
+
predicates.push("id > ?");
|
|
56
|
+
parameters.push(cursor);
|
|
57
|
+
}
|
|
58
|
+
const rows = await connection.execute(
|
|
59
|
+
`select id, entity_type, tenant_id, organization_id, doc from custom_entities_storage where ${predicates.join(" and ")} order by id asc limit ${BATCH_SIZE}`,
|
|
60
|
+
parameters
|
|
61
|
+
);
|
|
62
|
+
if (!rows.length) break;
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const scopeKey = JSON.stringify([row.entity_type, row.tenant_id, row.organization_id]);
|
|
65
|
+
let policy = fieldsByScope.get(scopeKey);
|
|
66
|
+
if (!policy) {
|
|
67
|
+
const scopedDefinitions = await loadScopedCustomFieldDefs(em, {
|
|
68
|
+
entityId: row.entity_type,
|
|
69
|
+
tenantId: row.tenant_id,
|
|
70
|
+
organizationId: row.organization_id
|
|
71
|
+
});
|
|
72
|
+
const visibleDefinitions = definitions.filter((definition) => definition.entityId === row.entity_type && (definition.tenantId == null || definition.tenantId === row.tenant_id) && (definition.organizationId == null || definition.organizationId === row.organization_id));
|
|
73
|
+
const keys = new Set(visibleDefinitions.map((definition) => definition.key));
|
|
74
|
+
const historicalDefinitions = selectHistoricalDefinitions(visibleDefinitions);
|
|
75
|
+
policy = {
|
|
76
|
+
fields: Array.from(keys, (key) => ({ key, kind: "text", encrypted: true })),
|
|
77
|
+
protectedKeys: new Set(Array.from(keys).filter((key) => (scopedDefinitions.get(key) ?? historicalDefinitions.get(key))?.configJson?.encrypted === true)),
|
|
78
|
+
encryptedKeys: new Set(Array.from(scopedDefinitions.values()).filter((definition) => definition.configJson?.encrypted === true).map((definition) => definition.key))
|
|
79
|
+
};
|
|
80
|
+
fieldsByScope.set(scopeKey, policy);
|
|
81
|
+
}
|
|
82
|
+
const { fields, encryptedKeys, protectedKeys } = policy;
|
|
83
|
+
const knownKeys = new Set(fields.map((field) => field.key));
|
|
84
|
+
const entries = Object.entries(row.doc).filter(([key, value]) => {
|
|
85
|
+
const normalizedKey = key.replace(/^cf[:_]/, "");
|
|
86
|
+
return value != null && knownKeys.has(normalizedKey) && (encryptedKeys.has(normalizedKey) || holdsCiphertext(value));
|
|
87
|
+
});
|
|
88
|
+
if (!entries.length) continue;
|
|
89
|
+
const tenantId = row.tenant_id;
|
|
90
|
+
if (!tenantId || !options.encryptionService.isEnabled()) {
|
|
91
|
+
throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.KMS_UNAVAILABLE, "[internal] Custom entity encryption requires an available tenant key");
|
|
92
|
+
}
|
|
93
|
+
const currentDek = await options.encryptionService.getDek(tenantId);
|
|
94
|
+
if (options.oldKms && !oldDeks.has(tenantId)) oldDeks.set(tenantId, await options.oldKms.getTenantDek(tenantId));
|
|
95
|
+
const oldDek = oldDeks.get(tenantId) ?? null;
|
|
96
|
+
const candidates = {};
|
|
97
|
+
const arrayMasks = /* @__PURE__ */ new Map();
|
|
98
|
+
let changed = false;
|
|
99
|
+
const prepare = (value, encryptPlaintext, requireCiphertextKey) => {
|
|
100
|
+
if (value == null) return { value, selected: false };
|
|
101
|
+
if (typeof value === "string") {
|
|
102
|
+
if (currentDek && decryptWithAesGcm(value, currentDek.key) !== null) return { value, selected: true };
|
|
103
|
+
if (oldDek && decryptWithAesGcm(value, oldDek.key) !== null) {
|
|
104
|
+
changed = true;
|
|
105
|
+
return { value, selected: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!isCustomEntityDocumentCiphertext(value)) {
|
|
109
|
+
if (options.oldKms || !encryptPlaintext) return { value: null, selected: false };
|
|
110
|
+
changed = true;
|
|
111
|
+
return { value, selected: true };
|
|
112
|
+
}
|
|
113
|
+
if (!requireCiphertextKey) return { value: null, selected: false };
|
|
114
|
+
throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.WRONG_KEY, "[internal] Custom entity ciphertext does not decrypt under the supplied tenant keys");
|
|
115
|
+
};
|
|
116
|
+
for (const [key, value] of entries) {
|
|
117
|
+
const normalizedKey = key.replace(/^cf[:_]/, "");
|
|
118
|
+
const encryptPlaintext = encryptedKeys.has(normalizedKey);
|
|
119
|
+
const requireCiphertextKey = protectedKeys.has(normalizedKey);
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
const prepared = value.map((entry) => prepare(entry, encryptPlaintext, requireCiphertextKey));
|
|
122
|
+
candidates[key] = prepared.map((entry) => entry.value);
|
|
123
|
+
arrayMasks.set(key, prepared.map((entry) => entry.selected));
|
|
124
|
+
} else {
|
|
125
|
+
const prepared = prepare(value, encryptPlaintext, requireCiphertextKey);
|
|
126
|
+
if (prepared.selected) candidates[key] = prepared.value;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!changed) continue;
|
|
130
|
+
if (options.dryRun) {
|
|
131
|
+
updated += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const plaintext = options.oldKms && oldDek ? await decryptCustomEntityDocument(candidates, fields, tenantId, options.encryptionService, /* @__PURE__ */ new Map([[tenantId, oldDek.key]])) : candidates;
|
|
135
|
+
const encrypted = await encryptCustomEntityDocument(plaintext, fields, tenantId, options.encryptionService);
|
|
136
|
+
const nextDoc = { ...row.doc };
|
|
137
|
+
for (const [key, value] of Object.entries(encrypted)) {
|
|
138
|
+
const mask = arrayMasks.get(key);
|
|
139
|
+
const previous = row.doc[key];
|
|
140
|
+
nextDoc[key] = mask && Array.isArray(value) && Array.isArray(previous) ? value.map((entry, index) => mask[index] ? entry : previous[index]) : value;
|
|
141
|
+
}
|
|
142
|
+
const written = await connection.execute(
|
|
143
|
+
"update custom_entities_storage set doc = ?::jsonb, updated_at = now() where id = ? and entity_type = ? and tenant_id is not distinct from ? and organization_id is not distinct from ? and doc = ?::jsonb returning id",
|
|
144
|
+
[JSON.stringify(nextDoc), row.id, row.entity_type, tenantId, row.organization_id, JSON.stringify(row.doc)]
|
|
145
|
+
);
|
|
146
|
+
if (!written.length) throw new Error("[internal] Custom entity document changed during encryption; retry the operation");
|
|
147
|
+
updated += 1;
|
|
148
|
+
}
|
|
149
|
+
cursor = rows[rows.length - 1].id;
|
|
150
|
+
if (rows.length < BATCH_SIZE) break;
|
|
151
|
+
}
|
|
152
|
+
return updated;
|
|
153
|
+
}
|
|
154
|
+
export {
|
|
155
|
+
rotateCustomEntityDocuments
|
|
156
|
+
};
|
|
157
|
+
//# sourceMappingURL=rotate-custom-entity-documents.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/entities/lib/rotate-custom-entity-documents.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport {\n decryptWithAesGcm,\n TenantDataEncryptionError,\n TenantDataEncryptionErrorCode,\n} from '@open-mercato/shared/lib/encryption/aes'\nimport {\n decryptCustomEntityDocument,\n encryptCustomEntityDocument,\n isCustomEntityDocumentCiphertext,\n type CustomEntityDocumentField,\n} from '@open-mercato/shared/lib/encryption/customEntityDoc'\nimport type { KmsService, TenantDek } from '@open-mercato/shared/lib/encryption/kms'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { CustomFieldDef } from '../data/entities'\nimport { loadScopedCustomFieldDefs } from './scoped-field-defs'\n\ntype RotationOptions = {\n encryptionService: TenantDataEncryptionService\n oldKms: KmsService | null\n tenantId: string | null\n organizationId: string | null\n dryRun: boolean\n}\n\ntype DocumentRow = {\n id: string\n entity_type: string\n tenant_id: string | null\n organization_id: string | null\n doc: Record<string, unknown>\n}\n\ntype DocumentFieldPolicy = {\n fields: CustomEntityDocumentField[]\n encryptedKeys: Set<string>\n protectedKeys: Set<string>\n}\n\nconst BATCH_SIZE = 100\n\nfunction holdsCiphertext(value: unknown): boolean {\n return Array.isArray(value) ? value.some(isCustomEntityDocumentCiphertext) : isCustomEntityDocumentCiphertext(value)\n}\n\nfunction selectHistoricalDefinitions(definitions: CustomFieldDef[]): Map<string, CustomFieldDef> {\n const selected = new Map<string, CustomFieldDef>()\n const scopeScore = (definition: CustomFieldDef) => (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0)\n const updatedAt = (definition: CustomFieldDef) => new Date(definition.updatedAt).getTime()\n for (const definition of definitions) {\n const previous = selected.get(definition.key)\n if (!previous || scopeScore(definition) > scopeScore(previous)\n || (scopeScore(definition) === scopeScore(previous) && updatedAt(definition) >= updatedAt(previous))) {\n selected.set(definition.key, definition)\n }\n }\n return selected\n}\n\n/** @internal */\nexport async function rotateCustomEntityDocuments(\n em: EntityManager,\n options: RotationOptions,\n): Promise<number> {\n const definitionScope: FilterQuery<CustomFieldDef>[] = []\n if (options.tenantId) definitionScope.push({ $or: [{ tenantId: options.tenantId }, { tenantId: null }] })\n if (options.organizationId) definitionScope.push({ $or: [{ organizationId: options.organizationId }, { organizationId: null }] })\n const definitions = await em.find(CustomFieldDef, {\n ...(definitionScope.length ? { $and: definitionScope } : {}),\n })\n const entityIds = Array.from(new Set(definitions.map((definition) => definition.entityId)))\n if (!entityIds.length) return 0\n\n const connection = em.getConnection()\n const fieldsByScope = new Map<string, DocumentFieldPolicy>()\n const oldDeks = new Map<string, TenantDek | null>()\n let cursor: string | null = null\n let updated = 0\n for (;;) {\n const predicates = [`entity_type in (${entityIds.map(() => '?').join(', ')})`]\n const parameters: unknown[] = [...entityIds]\n if (options.tenantId) {\n predicates.push('tenant_id = ?')\n parameters.push(options.tenantId)\n }\n if (options.organizationId) {\n predicates.push('organization_id = ?')\n parameters.push(options.organizationId)\n }\n if (cursor) {\n predicates.push('id > ?')\n parameters.push(cursor)\n }\n const rows = await connection.execute<DocumentRow[]>(\n `select id, entity_type, tenant_id, organization_id, doc from custom_entities_storage where ${predicates.join(' and ')} order by id asc limit ${BATCH_SIZE}`,\n parameters,\n )\n if (!rows.length) break\n for (const row of rows) {\n const scopeKey = JSON.stringify([row.entity_type, row.tenant_id, row.organization_id])\n let policy = fieldsByScope.get(scopeKey)\n if (!policy) {\n const scopedDefinitions = await loadScopedCustomFieldDefs(em, {\n entityId: row.entity_type,\n tenantId: row.tenant_id,\n organizationId: row.organization_id,\n })\n const visibleDefinitions = definitions\n .filter((definition) => definition.entityId === row.entity_type\n && (definition.tenantId == null || definition.tenantId === row.tenant_id)\n && (definition.organizationId == null || definition.organizationId === row.organization_id))\n const keys = new Set(visibleDefinitions.map((definition) => definition.key))\n const historicalDefinitions = selectHistoricalDefinitions(visibleDefinitions)\n policy = {\n fields: Array.from(keys, (key) => ({ key, kind: 'text', encrypted: true })),\n protectedKeys: new Set(Array.from(keys)\n .filter((key) => (scopedDefinitions.get(key) ?? historicalDefinitions.get(key))?.configJson?.encrypted === true)),\n encryptedKeys: new Set(Array.from(scopedDefinitions.values())\n .filter((definition) => definition.configJson?.encrypted === true)\n .map((definition) => definition.key)),\n }\n fieldsByScope.set(scopeKey, policy)\n }\n const { fields, encryptedKeys, protectedKeys } = policy\n const knownKeys = new Set(fields.map((field) => field.key))\n const entries = Object.entries(row.doc).filter(([key, value]) => {\n const normalizedKey = key.replace(/^cf[:_]/, '')\n return value != null && knownKeys.has(normalizedKey) && (encryptedKeys.has(normalizedKey) || holdsCiphertext(value))\n })\n if (!entries.length) continue\n const tenantId = row.tenant_id\n if (!tenantId || !options.encryptionService.isEnabled()) {\n throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.KMS_UNAVAILABLE, '[internal] Custom entity encryption requires an available tenant key')\n }\n const currentDek = await options.encryptionService.getDek(tenantId)\n if (options.oldKms && !oldDeks.has(tenantId)) oldDeks.set(tenantId, await options.oldKms.getTenantDek(tenantId))\n const oldDek = oldDeks.get(tenantId) ?? null\n const candidates: Record<string, unknown> = {}\n const arrayMasks = new Map<string, boolean[]>()\n let changed = false\n const prepare = (value: unknown, encryptPlaintext: boolean, requireCiphertextKey: boolean): { value: unknown; selected: boolean } => {\n if (value == null) return { value, selected: false }\n if (typeof value === 'string') {\n if (currentDek && decryptWithAesGcm(value, currentDek.key) !== null) return { value, selected: true }\n if (oldDek && decryptWithAesGcm(value, oldDek.key) !== null) {\n changed = true\n return { value, selected: true }\n }\n }\n if (!isCustomEntityDocumentCiphertext(value)) {\n if (options.oldKms || !encryptPlaintext) return { value: null, selected: false }\n changed = true\n return { value, selected: true }\n }\n if (!requireCiphertextKey) return { value: null, selected: false }\n throw new TenantDataEncryptionError(TenantDataEncryptionErrorCode.WRONG_KEY, '[internal] Custom entity ciphertext does not decrypt under the supplied tenant keys')\n }\n for (const [key, value] of entries) {\n const normalizedKey = key.replace(/^cf[:_]/, '')\n const encryptPlaintext = encryptedKeys.has(normalizedKey)\n const requireCiphertextKey = protectedKeys.has(normalizedKey)\n if (Array.isArray(value)) {\n const prepared = value.map((entry) => prepare(entry, encryptPlaintext, requireCiphertextKey))\n candidates[key] = prepared.map((entry) => entry.value)\n arrayMasks.set(key, prepared.map((entry) => entry.selected))\n } else {\n const prepared = prepare(value, encryptPlaintext, requireCiphertextKey)\n if (prepared.selected) candidates[key] = prepared.value\n }\n }\n if (!changed) continue\n if (options.dryRun) {\n updated += 1\n continue\n }\n const plaintext = options.oldKms && oldDek\n ? await decryptCustomEntityDocument(candidates, fields, tenantId, options.encryptionService, new Map([[tenantId, oldDek.key]]))\n : candidates\n const encrypted = await encryptCustomEntityDocument(plaintext, fields, tenantId, options.encryptionService)\n const nextDoc = { ...row.doc }\n for (const [key, value] of Object.entries(encrypted)) {\n const mask = arrayMasks.get(key)\n const previous = row.doc[key]\n nextDoc[key] = mask && Array.isArray(value) && Array.isArray(previous)\n ? value.map((entry, index) => mask[index] ? entry : previous[index])\n : value\n }\n const written = await connection.execute<Array<{ id: string }>>(\n 'update custom_entities_storage set doc = ?::jsonb, updated_at = now() where id = ? and entity_type = ? and tenant_id is not distinct from ? and organization_id is not distinct from ? and doc = ?::jsonb returning id',\n [JSON.stringify(nextDoc), row.id, row.entity_type, tenantId, row.organization_id, JSON.stringify(row.doc)],\n )\n if (!written.length) throw new Error('[internal] Custom entity document changed during encryption; retry the operation')\n updated += 1\n }\n cursor = rows[rows.length - 1]!.id\n if (rows.length < BATCH_SIZE) break\n }\n return updated\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -26,7 +26,7 @@ async function getScopedNotificationRecipientUserIds(db, tenantId, organizationI
|
|
|
26
26
|
}
|
|
27
27
|
async function getRecipientUserIdsForRole(db, tenantId, roleId) {
|
|
28
28
|
const builder = db;
|
|
29
|
-
const userRoles = await builder.selectFrom("user_roles").innerJoin("users", "user_roles.user_id", "users.id").where("user_roles.role_id", "=", roleId).where("user_roles.deleted_at", "is", null).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select("users.id as user_id").execute();
|
|
29
|
+
const userRoles = await builder.selectFrom("user_roles").innerJoin("roles", "user_roles.role_id", "roles.id").innerJoin("users", "user_roles.user_id", "users.id").where("user_roles.role_id", "=", roleId).where("user_roles.deleted_at", "is", null).where("roles.deleted_at", "is", null).where("roles.tenant_id", "=", tenantId).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select("users.id as user_id").execute();
|
|
30
30
|
return userRoles.map((row) => row.user_id);
|
|
31
31
|
}
|
|
32
32
|
async function getRecipientUserIdsForFeature(db, tenantId, requiredFeature) {
|
|
@@ -38,7 +38,7 @@ async function getRecipientUserIdsForFeature(db, tenantId, requiredFeature) {
|
|
|
38
38
|
"user_acls.is_super_admin"
|
|
39
39
|
]).execute();
|
|
40
40
|
collectUsersWithFeature(userIdsSet, userAcls, requiredFeature);
|
|
41
|
-
const roleAcls = await builder.selectFrom("role_acls").innerJoin("user_roles", "role_acls.role_id", "user_roles.role_id").innerJoin("users", "user_roles.user_id", "users.id").where("role_acls.tenant_id", "=", tenantId).where("role_acls.deleted_at", "is", null).where("user_roles.deleted_at", "is", null).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select([
|
|
41
|
+
const roleAcls = await builder.selectFrom("role_acls").innerJoin("roles", "role_acls.role_id", "roles.id").innerJoin("user_roles", "role_acls.role_id", "user_roles.role_id").innerJoin("users", "user_roles.user_id", "users.id").where("role_acls.tenant_id", "=", tenantId).where("role_acls.deleted_at", "is", null).where("roles.deleted_at", "is", null).where("roles.tenant_id", "=", tenantId).where("user_roles.deleted_at", "is", null).where("users.deleted_at", "is", null).where("users.tenant_id", "=", tenantId).select([
|
|
42
42
|
"users.id as user_id",
|
|
43
43
|
"role_acls.features_json",
|
|
44
44
|
"role_acls.is_super_admin"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/notifications/lib/notificationRecipients.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\ninterface AclRow {\n user_id: string\n features_json: unknown\n is_super_admin: boolean\n}\n\nfunction normalizeFeatures(features: unknown): string[] | undefined {\n if (!Array.isArray(features)) return undefined\n const normalized = features.filter((feature): feature is string => typeof feature === 'string')\n return normalized.length ? normalized : undefined\n}\n\n/**\n * Extract user IDs from ACL rows that have the required feature or are super admins.\n */\nfunction collectUsersWithFeature(\n userIdsSet: Set<string>,\n rows: AclRow[],\n requiredFeature: string\n): void {\n for (const row of rows) {\n const features = normalizeFeatures(row.features_json) ?? []\n if (authorizeFeatures([requiredFeature], {\n grantedFeatures: features,\n unrestricted: row.is_super_admin,\n })) {\n userIdsSet.add(row.user_id)\n }\n }\n}\n\nexport async function getScopedNotificationRecipientUserIds(\n db: Kysely<any>,\n tenantId: string,\n organizationId: string | null,\n recipientUserIds: string[],\n): Promise<string[]> {\n const builder: any = db\n let query = builder\n .selectFrom('users')\n .where('users.id', 'in', recipientUserIds)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n\n if (organizationId) {\n query = query.where('users.organization_id', '=', organizationId)\n }\n\n const users = await query\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return users.map((user) => user.user_id)\n}\n\nexport async function getRecipientUserIdsForRole(\n db: Kysely<any>,\n tenantId: string,\n roleId: string\n): Promise<string[]> {\n const builder: any = db\n const userRoles = await builder\n .selectFrom('user_roles')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('user_roles.role_id', '=', roleId)\n .where('user_roles.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return userRoles.map((row) => row.user_id)\n}\n\nexport async function getRecipientUserIdsForFeature(\n db: Kysely<any>,\n tenantId: string,\n requiredFeature: string\n): Promise<string[]> {\n const userIdsSet = new Set<string>()\n const builder: any = db\n\n const userAcls = await builder\n .selectFrom('user_acls')\n .innerJoin('users', 'user_acls.user_id', 'users.id')\n .where('user_acls.tenant_id', '=', tenantId)\n .where('user_acls.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'user_acls.features_json',\n 'user_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, userAcls, requiredFeature)\n\n const roleAcls = await builder\n .selectFrom('role_acls')\n .innerJoin('user_roles', 'role_acls.role_id', 'user_roles.role_id')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('role_acls.tenant_id', '=', tenantId)\n .where('role_acls.deleted_at', 'is', null)\n .where('user_roles.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'role_acls.features_json',\n 'role_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, roleAcls, requiredFeature)\n\n return Array.from(userIdsSet)\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,yBAAyB;AAQlC,SAAS,kBAAkB,UAAyC;AAClE,MAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACrC,QAAM,aAAa,SAAS,OAAO,CAAC,YAA+B,OAAO,YAAY,QAAQ;AAC9F,SAAO,WAAW,SAAS,aAAa;AAC1C;AAKA,SAAS,wBACP,YACA,MACA,iBACM;AACN,aAAW,OAAO,MAAM;AACtB,UAAM,WAAW,kBAAkB,IAAI,aAAa,KAAK,CAAC;AAC1D,QAAI,kBAAkB,CAAC,eAAe,GAAG;AAAA,MACvC,iBAAiB;AAAA,MACjB,cAAc,IAAI;AAAA,IACpB,CAAC,GAAG;AACF,iBAAW,IAAI,IAAI,OAAO;AAAA,IAC5B;AAAA,EACF;AACF;AAEA,eAAsB,sCACpB,IACA,UACA,gBACA,kBACmB;AACnB,QAAM,UAAe;AACrB,MAAI,QAAQ,QACT,WAAW,OAAO,EAClB,MAAM,YAAY,MAAM,gBAAgB,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ;AAEzC,MAAI,gBAAgB;AAClB,YAAQ,MAAM,MAAM,yBAAyB,KAAK,cAAc;AAAA,EAClE;AAEA,QAAM,QAAQ,MAAM,MACjB,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,MAAM,IAAI,CAAC,SAAS,KAAK,OAAO;AACzC;AAEA,eAAsB,2BACpB,IACA,UACA,QACmB;AACnB,QAAM,UAAe;AACrB,QAAM,YAAY,MAAM,QACrB,WAAW,YAAY,EACvB,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,sBAAsB,KAAK,MAAM,EACvC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,UAAU,IAAI,CAAC,QAAQ,IAAI,OAAO;AAC3C;AAEA,eAAsB,8BACpB,IACA,UACA,iBACmB;AACnB,QAAM,aAAa,oBAAI,IAAY;AACnC,QAAM,UAAe;AAErB,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,SAAS,qBAAqB,UAAU,EAClD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,cAAc,qBAAqB,oBAAoB,EACjE,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,SAAO,MAAM,KAAK,UAAU;AAC9B;",
|
|
4
|
+
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\ninterface AclRow {\n user_id: string\n features_json: unknown\n is_super_admin: boolean\n}\n\nfunction normalizeFeatures(features: unknown): string[] | undefined {\n if (!Array.isArray(features)) return undefined\n const normalized = features.filter((feature): feature is string => typeof feature === 'string')\n return normalized.length ? normalized : undefined\n}\n\n/**\n * Extract user IDs from ACL rows that have the required feature or are super admins.\n */\nfunction collectUsersWithFeature(\n userIdsSet: Set<string>,\n rows: AclRow[],\n requiredFeature: string\n): void {\n for (const row of rows) {\n const features = normalizeFeatures(row.features_json) ?? []\n if (authorizeFeatures([requiredFeature], {\n grantedFeatures: features,\n unrestricted: row.is_super_admin,\n })) {\n userIdsSet.add(row.user_id)\n }\n }\n}\n\nexport async function getScopedNotificationRecipientUserIds(\n db: Kysely<any>,\n tenantId: string,\n organizationId: string | null,\n recipientUserIds: string[],\n): Promise<string[]> {\n const builder: any = db\n let query = builder\n .selectFrom('users')\n .where('users.id', 'in', recipientUserIds)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n\n if (organizationId) {\n query = query.where('users.organization_id', '=', organizationId)\n }\n\n const users = await query\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return users.map((user) => user.user_id)\n}\n\nexport async function getRecipientUserIdsForRole(\n db: Kysely<any>,\n tenantId: string,\n roleId: string\n): Promise<string[]> {\n const builder: any = db\n const userRoles = await builder\n .selectFrom('user_roles')\n .innerJoin('roles', 'user_roles.role_id', 'roles.id')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('user_roles.role_id', '=', roleId)\n .where('user_roles.deleted_at', 'is', null)\n .where('roles.deleted_at', 'is', null)\n .where('roles.tenant_id', '=', tenantId)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select('users.id as user_id')\n .execute() as Array<{ user_id: string }>\n\n return userRoles.map((row) => row.user_id)\n}\n\nexport async function getRecipientUserIdsForFeature(\n db: Kysely<any>,\n tenantId: string,\n requiredFeature: string\n): Promise<string[]> {\n const userIdsSet = new Set<string>()\n const builder: any = db\n\n const userAcls = await builder\n .selectFrom('user_acls')\n .innerJoin('users', 'user_acls.user_id', 'users.id')\n .where('user_acls.tenant_id', '=', tenantId)\n .where('user_acls.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'user_acls.features_json',\n 'user_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, userAcls, requiredFeature)\n\n const roleAcls = await builder\n .selectFrom('role_acls')\n .innerJoin('roles', 'role_acls.role_id', 'roles.id')\n .innerJoin('user_roles', 'role_acls.role_id', 'user_roles.role_id')\n .innerJoin('users', 'user_roles.user_id', 'users.id')\n .where('role_acls.tenant_id', '=', tenantId)\n .where('role_acls.deleted_at', 'is', null)\n .where('roles.deleted_at', 'is', null)\n .where('roles.tenant_id', '=', tenantId)\n .where('user_roles.deleted_at', 'is', null)\n .where('users.deleted_at', 'is', null)\n .where('users.tenant_id', '=', tenantId)\n .select([\n 'users.id as user_id',\n 'role_acls.features_json',\n 'role_acls.is_super_admin',\n ])\n .execute() as AclRow[]\n\n collectUsersWithFeature(userIdsSet, roleAcls, requiredFeature)\n\n return Array.from(userIdsSet)\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,yBAAyB;AAQlC,SAAS,kBAAkB,UAAyC;AAClE,MAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACrC,QAAM,aAAa,SAAS,OAAO,CAAC,YAA+B,OAAO,YAAY,QAAQ;AAC9F,SAAO,WAAW,SAAS,aAAa;AAC1C;AAKA,SAAS,wBACP,YACA,MACA,iBACM;AACN,aAAW,OAAO,MAAM;AACtB,UAAM,WAAW,kBAAkB,IAAI,aAAa,KAAK,CAAC;AAC1D,QAAI,kBAAkB,CAAC,eAAe,GAAG;AAAA,MACvC,iBAAiB;AAAA,MACjB,cAAc,IAAI;AAAA,IACpB,CAAC,GAAG;AACF,iBAAW,IAAI,IAAI,OAAO;AAAA,IAC5B;AAAA,EACF;AACF;AAEA,eAAsB,sCACpB,IACA,UACA,gBACA,kBACmB;AACnB,QAAM,UAAe;AACrB,MAAI,QAAQ,QACT,WAAW,OAAO,EAClB,MAAM,YAAY,MAAM,gBAAgB,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ;AAEzC,MAAI,gBAAgB;AAClB,YAAQ,MAAM,MAAM,yBAAyB,KAAK,cAAc;AAAA,EAClE;AAEA,QAAM,QAAQ,MAAM,MACjB,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,MAAM,IAAI,CAAC,SAAS,KAAK,OAAO;AACzC;AAEA,eAAsB,2BACpB,IACA,UACA,QACmB;AACnB,QAAM,UAAe;AACrB,QAAM,YAAY,MAAM,QACrB,WAAW,YAAY,EACvB,UAAU,SAAS,sBAAsB,UAAU,EACnD,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,sBAAsB,KAAK,MAAM,EACvC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO,qBAAqB,EAC5B,QAAQ;AAEX,SAAO,UAAU,IAAI,CAAC,QAAQ,IAAI,OAAO;AAC3C;AAEA,eAAsB,8BACpB,IACA,UACA,iBACmB;AACnB,QAAM,aAAa,oBAAI,IAAY;AACnC,QAAM,UAAe;AAErB,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,SAAS,qBAAqB,UAAU,EAClD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,QAAM,WAAW,MAAM,QACpB,WAAW,WAAW,EACtB,UAAU,SAAS,qBAAqB,UAAU,EAClD,UAAU,cAAc,qBAAqB,oBAAoB,EACjE,UAAU,SAAS,sBAAsB,UAAU,EACnD,MAAM,uBAAuB,KAAK,QAAQ,EAC1C,MAAM,wBAAwB,MAAM,IAAI,EACxC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,MAAM,yBAAyB,MAAM,IAAI,EACzC,MAAM,oBAAoB,MAAM,IAAI,EACpC,MAAM,mBAAmB,KAAK,QAAQ,EACtC,OAAO;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC,EACA,QAAQ;AAEX,0BAAwB,YAAY,UAAU,eAAe;AAE7D,SAAO,MAAM,KAAK,UAAU;AAC9B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -12,6 +12,7 @@ import { sql } from "kysely";
|
|
|
12
12
|
import { readCoverageSnapshot, refreshCoverageSnapshot } from "./coverage.js";
|
|
13
13
|
import { createProfiler, shouldEnableProfiler } from "@open-mercato/shared/lib/profiler";
|
|
14
14
|
import { decryptIndexDocCustomFields } from "@open-mercato/shared/lib/encryption/indexDoc";
|
|
15
|
+
import { decryptCustomEntityDocument, isCustomEntityDocumentCiphertext } from "@open-mercato/shared/lib/encryption/customEntityDoc";
|
|
15
16
|
import {
|
|
16
17
|
DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
|
|
17
18
|
DECRYPT_REFUSAL_LOG_MESSAGE,
|
|
@@ -58,6 +59,91 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
58
59
|
import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
|
|
59
60
|
import { createBoundedTtlMemo } from "@open-mercato/shared/lib/query/bounded-ttl-memo";
|
|
60
61
|
const logger = createLogger("query_index").child({ component: "engine" });
|
|
62
|
+
function customDocumentValue(row, field) {
|
|
63
|
+
const bare = field.replace(/^cf[:_]/, "");
|
|
64
|
+
return row[field] ?? row[`cf_${bare.replace(/[^a-zA-Z0-9_]/g, "_")}`] ?? row[`cf:${bare}`] ?? row[bare];
|
|
65
|
+
}
|
|
66
|
+
function assertCustomDocumentPlaintext(actual) {
|
|
67
|
+
if (isCustomEntityDocumentCiphertext(actual) || Array.isArray(actual) && actual.some(isCustomEntityDocumentCiphertext)) {
|
|
68
|
+
throw new Error("[internal] Cannot compare custom entity ciphertext without its tenant key");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function matchesCustomDocumentPattern(value, pattern, ignoreCase) {
|
|
72
|
+
const source = Array.from(value);
|
|
73
|
+
const authored = Array.from(pattern);
|
|
74
|
+
const normalize = (character) => ignoreCase ? character.toLowerCase() : character;
|
|
75
|
+
const tokens = [];
|
|
76
|
+
for (let index = 0; index < authored.length; index += 1) {
|
|
77
|
+
const character = authored[index];
|
|
78
|
+
if (character === "\\") {
|
|
79
|
+
const literal = authored[++index];
|
|
80
|
+
if (literal === void 0) throw new Error("[internal] Invalid custom entity LIKE escape");
|
|
81
|
+
tokens.push({ kind: "literal", value: normalize(literal) });
|
|
82
|
+
} else {
|
|
83
|
+
tokens.push({ kind: character === "%" ? "many" : character === "_" ? "one" : "literal", value: normalize(character) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let sourceIndex = 0;
|
|
87
|
+
let tokenIndex = 0;
|
|
88
|
+
let wildcardIndex = -1;
|
|
89
|
+
let retryIndex = 0;
|
|
90
|
+
while (sourceIndex < source.length) {
|
|
91
|
+
const token = tokens[tokenIndex];
|
|
92
|
+
if (token?.kind === "one" || token?.kind === "literal" && token.value === normalize(source[sourceIndex])) {
|
|
93
|
+
sourceIndex += 1;
|
|
94
|
+
tokenIndex += 1;
|
|
95
|
+
} else if (token?.kind === "many") {
|
|
96
|
+
wildcardIndex = tokenIndex++;
|
|
97
|
+
retryIndex = sourceIndex;
|
|
98
|
+
} else if (wildcardIndex >= 0) {
|
|
99
|
+
tokenIndex = wildcardIndex + 1;
|
|
100
|
+
sourceIndex = ++retryIndex;
|
|
101
|
+
} else {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
while (tokens[tokenIndex]?.kind === "many") tokenIndex += 1;
|
|
106
|
+
return tokenIndex === tokens.length;
|
|
107
|
+
}
|
|
108
|
+
function matchesCustomDocumentFilter(row, filter, actual = customDocumentValue(row, filter.field)) {
|
|
109
|
+
const expected = filter.value;
|
|
110
|
+
assertCustomDocumentPlaintext(actual);
|
|
111
|
+
const equal = (left, right) => left == null || right == null ? left == null && right == null : String(left) === String(right);
|
|
112
|
+
const values = filter.field.startsWith("cf:") && Array.isArray(actual) ? actual : [actual];
|
|
113
|
+
const included = (candidates) => values.some((value) => candidates.some((candidate) => equal(value, candidate)));
|
|
114
|
+
switch (filter.op) {
|
|
115
|
+
case "exists":
|
|
116
|
+
return Boolean(expected) === (actual != null);
|
|
117
|
+
case "eq":
|
|
118
|
+
return values.some((value) => equal(value, expected));
|
|
119
|
+
case "ne":
|
|
120
|
+
return actual != null && !values.some((value) => equal(value, expected));
|
|
121
|
+
case "in":
|
|
122
|
+
return included(Array.isArray(expected) ? expected : []);
|
|
123
|
+
case "nin":
|
|
124
|
+
return actual != null && !included(Array.isArray(expected) ? expected : []);
|
|
125
|
+
case "overlap":
|
|
126
|
+
return Array.isArray(actual) && normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value));
|
|
127
|
+
case "noverlap":
|
|
128
|
+
return Array.isArray(actual) && !normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value));
|
|
129
|
+
case "gt":
|
|
130
|
+
case "gte":
|
|
131
|
+
case "lt":
|
|
132
|
+
case "lte": {
|
|
133
|
+
if (actual == null || expected == null) return false;
|
|
134
|
+
const left = typeof actual === "number" ? actual : String(actual);
|
|
135
|
+
const right = typeof actual === "number" ? Number(expected) : String(expected);
|
|
136
|
+
const order = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right));
|
|
137
|
+
return filter.op === "gt" ? order > 0 : filter.op === "gte" ? order >= 0 : filter.op === "lt" ? order < 0 : order <= 0;
|
|
138
|
+
}
|
|
139
|
+
case "like":
|
|
140
|
+
case "ilike": {
|
|
141
|
+
return values.some((value) => value != null && matchesCustomDocumentPattern(String(value), String(expected ?? ""), filter.op === "ilike"));
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
throw new Error("[internal] Unsupported custom entity filter operator");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
61
147
|
const CF_FILTER_SUPPORTED_OPS = /* @__PURE__ */ new Set([
|
|
62
148
|
"eq",
|
|
63
149
|
"ne",
|
|
@@ -1752,6 +1838,20 @@ class HybridQueryEngine {
|
|
|
1752
1838
|
const orgScope = this.resolveOrganizationScope(opts);
|
|
1753
1839
|
if (!opts.tenantId) throw new Error("QueryEngine: tenantId is required");
|
|
1754
1840
|
const normalizedFilters = normalizeFilters(opts.filters);
|
|
1841
|
+
const encryptionService = resolveDecryptEnabled(opts) ? this.getEncryptionService() : null;
|
|
1842
|
+
const documentEncryptionService = encryptionService && "getDek" in encryptionService && typeof encryptionService.getDek === "function" && typeof encryptionService.isEnabled === "function" ? encryptionService : null;
|
|
1843
|
+
const definitions = encryptionService ? await this.resolveCustomFieldDefs([entity], opts.tenantId, true) : [];
|
|
1844
|
+
const resolveKinds = createCustomFieldKindMapResolver(buildCustomFieldKindIndex(definitions));
|
|
1845
|
+
const encryptedKeys = new Set(definitions.filter((definition) => {
|
|
1846
|
+
const config = definition.configJson;
|
|
1847
|
+
return config != null && typeof config === "object" && "encrypted" in config && Boolean(config.encrypted);
|
|
1848
|
+
}).map((definition) => definition.key));
|
|
1849
|
+
const isProtected = (field) => encryptedKeys.has(field.replace(/^cf[:_]/, ""));
|
|
1850
|
+
const plaintextFilters = normalizedFilters.some((filter) => isProtected(filter.field));
|
|
1851
|
+
const plaintextSort = (opts.sort ?? []).some((sort) => isProtected(sort.field));
|
|
1852
|
+
const scanPlaintext = plaintextFilters || plaintextSort;
|
|
1853
|
+
const deferredFilters = plaintextFilters ? normalizedFilters.filter((filter) => filter.orGroup || isProtected(filter.field)) : [];
|
|
1854
|
+
const jsonComparisonAliases = new Map(Array.from(new Set(deferredFilters.filter((filter) => !isProtected(filter.field) && !filter.field.startsWith("cf:") && !this.resolveCustomEntityColumn(alias, filter.field) && (filter.op === "overlap" || filter.op === "noverlap")).map((filter) => filter.field)), (field, index) => [field, `__om_ce_filter_${index}`]));
|
|
1755
1855
|
const searchConfig = resolveSearchConfig();
|
|
1756
1856
|
const searchEnabled = await this.searchAvailability().staticEnabled();
|
|
1757
1857
|
const hasSearchTokens = searchEnabled && hasSearchFilter(normalizedFilters) ? await this.searchAvailability().hasTokens(entity, opts.tenantId ?? null, orgScope) : false;
|
|
@@ -1769,6 +1869,7 @@ class HybridQueryEngine {
|
|
|
1769
1869
|
}
|
|
1770
1870
|
if (!opts.withDeleted) next = next.where(`${alias}.deleted_at`, "is", null);
|
|
1771
1871
|
for (const filter of normalizedFilters) {
|
|
1872
|
+
if (plaintextFilters && (filter.orGroup || isProtected(filter.field))) continue;
|
|
1772
1873
|
if (filter.field.startsWith("cf:")) {
|
|
1773
1874
|
next = this.applyCfFilterFromAlias(next, alias, entity, filter.field, filter.op, filter.value, searchRuntime);
|
|
1774
1875
|
continue;
|
|
@@ -1812,8 +1913,8 @@ class HybridQueryEngine {
|
|
|
1812
1913
|
}
|
|
1813
1914
|
if (opts.includeCustomFields === true) {
|
|
1814
1915
|
try {
|
|
1815
|
-
const
|
|
1816
|
-
for (const row of
|
|
1916
|
+
const rows2 = await db.selectFrom("custom_field_defs").select("key").where("entity_id", "=", entity).where("is_active", "=", true).where("tenant_id", "=", opts.tenantId).execute();
|
|
1917
|
+
for (const row of rows2) {
|
|
1817
1918
|
const key = row.key;
|
|
1818
1919
|
if (typeof key === "string") cfKeys.add(key);
|
|
1819
1920
|
else if (key != null) cfKeys.add(String(key));
|
|
@@ -1823,20 +1924,26 @@ class HybridQueryEngine {
|
|
|
1823
1924
|
} else if (Array.isArray(opts.includeCustomFields)) {
|
|
1824
1925
|
for (const k of opts.includeCustomFields) cfKeys.add(k);
|
|
1825
1926
|
}
|
|
1927
|
+
const projectedFields = new Set((opts.fields?.length ? opts.fields : ["id"]).map((field) => field.startsWith("cf:") ? this.sanitize(field) : field));
|
|
1928
|
+
for (const key of cfKeys) projectedFields.add(this.sanitize(`cf:${key}`));
|
|
1929
|
+
const hiddenFields = /* @__PURE__ */ new Set();
|
|
1930
|
+
const comparisonFields = scanPlaintext ? Array.from(/* @__PURE__ */ new Set([...deferredFilters.map((filter) => filter.field), ...(opts.sort ?? []).map((sort) => sort.field)])) : [];
|
|
1931
|
+
for (const field of comparisonFields) {
|
|
1932
|
+
const outputKey = field.startsWith("cf:") ? this.sanitize(field) : field;
|
|
1933
|
+
if (!projectedFields.has(outputKey)) hiddenFields.add(outputKey);
|
|
1934
|
+
}
|
|
1826
1935
|
const applySelection = (q) => {
|
|
1827
1936
|
let next = q;
|
|
1828
|
-
const requested =
|
|
1937
|
+
const requested = Array.from(/* @__PURE__ */ new Set([...opts.fields?.length ? opts.fields : ["id"], ...comparisonFields]));
|
|
1829
1938
|
for (const field of requested) {
|
|
1830
1939
|
const f = String(field);
|
|
1831
1940
|
if (f.startsWith("cf:")) {
|
|
1832
1941
|
const aliasName = this.sanitize(f);
|
|
1833
1942
|
next = next.select(this.jsonbSqlAlias(alias, f).as(aliasName));
|
|
1834
|
-
} else if (f
|
|
1835
|
-
next = next.select(`${alias}
|
|
1836
|
-
} else if (f === "created_at" || f === "updated_at" || f === "deleted_at") {
|
|
1837
|
-
next = next.select(`${alias}.${f} as ${f}`);
|
|
1943
|
+
} else if (this.resolveCustomEntityColumn(alias, f)) {
|
|
1944
|
+
next = next.select(`${this.resolveCustomEntityColumn(alias, f)} as ${f}`);
|
|
1838
1945
|
} else {
|
|
1839
|
-
const expr = sql`(${sql.ref(alias + ".doc")} ->> ${f})`;
|
|
1946
|
+
const expr = isProtected(f) ? sql`(${sql.ref(alias + ".doc")} -> ${f})` : sql`(${sql.ref(alias + ".doc")} ->> ${f})`;
|
|
1840
1947
|
next = next.select(expr.as(f));
|
|
1841
1948
|
}
|
|
1842
1949
|
}
|
|
@@ -1844,6 +1951,12 @@ class HybridQueryEngine {
|
|
|
1844
1951
|
const aliasName = this.sanitize(`cf:${key}`);
|
|
1845
1952
|
next = next.select(this.jsonbSqlAlias(alias, `cf:${key}`).as(aliasName));
|
|
1846
1953
|
}
|
|
1954
|
+
for (const [field, comparisonAlias] of jsonComparisonAliases) {
|
|
1955
|
+
next = next.select(sql`(${sql.ref(alias + ".doc")} -> ${field})`.as(comparisonAlias));
|
|
1956
|
+
}
|
|
1957
|
+
if (encryptionService) {
|
|
1958
|
+
next = next.select([`${alias}.tenant_id as __om_ce_tenant_id`, `${alias}.organization_id as __om_ce_organization_id`]);
|
|
1959
|
+
}
|
|
1847
1960
|
return next;
|
|
1848
1961
|
};
|
|
1849
1962
|
const applySort = (q) => {
|
|
@@ -1866,6 +1979,60 @@ class HybridQueryEngine {
|
|
|
1866
1979
|
};
|
|
1867
1980
|
const page = opts.page?.page ?? 1;
|
|
1868
1981
|
const pageSize = opts.page?.pageSize ?? 20;
|
|
1982
|
+
const decryptRow = async (item) => {
|
|
1983
|
+
let next = item;
|
|
1984
|
+
if (encryptionService) {
|
|
1985
|
+
const hasOrganizationScope = Object.prototype.hasOwnProperty.call(item, "__om_ce_organization_id");
|
|
1986
|
+
const decision = resolveDecryptScope({
|
|
1987
|
+
rowTenantId: Object.prototype.hasOwnProperty.call(item, "__om_ce_tenant_id") ? item.__om_ce_tenant_id : item.tenant_id ?? null,
|
|
1988
|
+
rowOrganizationId: hasOrganizationScope ? item.__om_ce_organization_id : item.organization_id ?? null,
|
|
1989
|
+
callerTenantId: opts.tenantId,
|
|
1990
|
+
callerOrganizationId: hasOrganizationScope ? null : opts.organizationId ?? null
|
|
1991
|
+
});
|
|
1992
|
+
if (decision.decrypt) {
|
|
1993
|
+
const fields = Object.entries(resolveKinds(decision.organizationId, decision.tenantId)).map(([key, kind]) => ({ key, kind, encrypted: encryptedKeys.has(key) }));
|
|
1994
|
+
next = await decryptCustomEntityDocument(item, fields, decision.tenantId, documentEncryptionService);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
const projected = { ...next };
|
|
1998
|
+
delete projected.__om_ce_tenant_id;
|
|
1999
|
+
delete projected.__om_ce_organization_id;
|
|
2000
|
+
return projected;
|
|
2001
|
+
};
|
|
2002
|
+
if (scanPlaintext) {
|
|
2003
|
+
const maxRows = resolveEncryptedSortMaxRows();
|
|
2004
|
+
let candidateQuery = applySelection(applyScope(db.selectFrom(`custom_entities_storage as ${alias}`)));
|
|
2005
|
+
if (maxRows !== null) candidateQuery = candidateQuery.limit(maxRows + 1);
|
|
2006
|
+
const rows2 = await candidateQuery.execute();
|
|
2007
|
+
if (maxRows !== null && rows2.length > maxRows) throw new Error("[internal] Custom entity encrypted query exceeds the candidate row limit");
|
|
2008
|
+
let candidates = await mapWithConcurrency(rows2, DECRYPT_CONCURRENCY, decryptRow);
|
|
2009
|
+
if (plaintextFilters) {
|
|
2010
|
+
const common = deferredFilters.filter((filter) => !filter.orGroup);
|
|
2011
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2012
|
+
for (const filter of normalizedFilters) {
|
|
2013
|
+
if (filter.orGroup) groups.set(filter.orGroup, [...groups.get(filter.orGroup) ?? [], filter]);
|
|
2014
|
+
}
|
|
2015
|
+
const matches = (row, filter) => {
|
|
2016
|
+
const comparisonAlias = jsonComparisonAliases.get(filter.field);
|
|
2017
|
+
return matchesCustomDocumentFilter(row, filter, comparisonAlias ? row[comparisonAlias] : customDocumentValue(row, filter.field));
|
|
2018
|
+
};
|
|
2019
|
+
candidates = candidates.filter((row) => common.every((filter) => matches(row, filter)) && (!groups.size || Array.from(groups.values()).some((group) => group.every((filter) => matches(row, filter)))));
|
|
2020
|
+
}
|
|
2021
|
+
for (const row of candidates) {
|
|
2022
|
+
for (const sort of opts.sort ?? []) {
|
|
2023
|
+
if (isProtected(sort.field)) assertCustomDocumentPlaintext(customDocumentValue(row, sort.field));
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
candidates = sortRowsInMemory(candidates, (opts.sort ?? []).map((sort) => ({ ...sort, field: sort.field.startsWith("cf:") ? this.sanitize(sort.field) : sort.field })));
|
|
2027
|
+
const total2 = candidates.length;
|
|
2028
|
+
const items2 = candidates.slice((page - 1) * pageSize, page * pageSize).map((row) => {
|
|
2029
|
+
const projected = { ...row };
|
|
2030
|
+
for (const field of hiddenFields) delete projected[field];
|
|
2031
|
+
for (const comparisonAlias of jsonComparisonAliases.values()) delete projected[comparisonAlias];
|
|
2032
|
+
return projected;
|
|
2033
|
+
});
|
|
2034
|
+
return { items: items2, page, pageSize, total: total2 };
|
|
2035
|
+
}
|
|
1869
2036
|
const countCap = resolveListCountCap();
|
|
1870
2037
|
const root = db.selectFrom(`custom_entities_storage as ${alias}`);
|
|
1871
2038
|
const countShape = applyScope(root);
|
|
@@ -1882,7 +2049,8 @@ class HybridQueryEngine {
|
|
|
1882
2049
|
dataQuery = applySelection(dataQuery);
|
|
1883
2050
|
dataQuery = applySort(dataQuery);
|
|
1884
2051
|
dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize);
|
|
1885
|
-
const
|
|
2052
|
+
const rows = await dataQuery.execute();
|
|
2053
|
+
const items = encryptionService ? await mapWithConcurrency(rows, DECRYPT_CONCURRENCY, decryptRow) : rows;
|
|
1886
2054
|
const result = { items, page, pageSize, total };
|
|
1887
2055
|
if (listCountCapWarning) result.meta = { listCountCapWarning };
|
|
1888
2056
|
return result;
|
|
@@ -1895,12 +2063,12 @@ class HybridQueryEngine {
|
|
|
1895
2063
|
if (present) baseTableExistsCache.set(table, true);
|
|
1896
2064
|
return present;
|
|
1897
2065
|
}
|
|
1898
|
-
async resolveCustomFieldDefs(entityIds, tenantId) {
|
|
2066
|
+
async resolveCustomFieldDefs(entityIds, tenantId, fresh = false) {
|
|
1899
2067
|
if (!entityIds.length) return [];
|
|
1900
2068
|
const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId);
|
|
1901
2069
|
const now = Date.now();
|
|
1902
2070
|
const cached = this.customFieldDefsCache.get(cacheKey);
|
|
1903
|
-
if (cached && cached.expiresAt > now) return cached.value.slice();
|
|
2071
|
+
if (!fresh && cached && cached.expiresAt > now) return cached.value.slice();
|
|
1904
2072
|
const db = this.getDb();
|
|
1905
2073
|
const rows = await db.selectFrom("custom_field_defs").select(["entity_id", "key", "kind", "organization_id", "tenant_id", "config_json", "updated_at"]).where("entity_id", "in", entityIds).where("is_active", "=", true).where((eb) => eb.or([
|
|
1906
2074
|
eb("tenant_id", "=", tenantId),
|