data-primals-engine 1.6.2 → 1.6.3

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.
@@ -14,6 +14,14 @@ export const defaultModels = {
14
14
  {
15
15
  "name": "description",
16
16
  "type": "richtext"
17
+ },
18
+ {
19
+ "name": "filter",
20
+ "type": "code",
21
+ "language": "json",
22
+ "conditionBuilder": true,
23
+ "required": false,
24
+ "hint": "Filtre JSON qui restreint la portée de cette permission. Le modèle cible est déduit du nom de la permission (ex: 'product.edit')."
17
25
  }
18
26
  ]
19
27
  },
@@ -1111,6 +1119,12 @@ export const defaultModels = {
1111
1119
  hint: "The current status of the workflow execution."
1112
1120
  },
1113
1121
  { name: 'stepExecutionsCount', type: 'object' },
1122
+ {
1123
+ name: 'history',
1124
+ type: 'array',
1125
+ itemsType: 'object',
1126
+ hint: "Stores the detailed execution history of each step and action."
1127
+ },
1114
1128
  { name: 'currentStep', type: 'relation', relation: 'workflowStep', hint: "The step currently being executed or waited on." },
1115
1129
  { name: 'owner', type: 'relation', relation: 'user', required: false },
1116
1130
  { name: 'startedAt', type: 'datetime', required: true, hint: "Timestamp when the workflow run began." },
package/src/filter.js CHANGED
@@ -1,3 +1,7 @@
1
+ import {getCollectionForUser, isObjectId} from "./modules/mongodb.js";
2
+ import {getModel} from "./modules/data/index.js";
3
+ import {ObjectId} from "mongodb";
4
+ import {safeAssignObject} from "./core.js";
1
5
 
2
6
  let safeRegex = null; // Par défaut, aucune fonction de validation n'est définie.
3
7
 
@@ -9,6 +13,42 @@ export function setSafeRegex(validator) {
9
13
  safeRegex = validator;
10
14
  }
11
15
 
16
+ /**
17
+ * Récupère une valeur imbriquée dans un objet en utilisant une chaîne de chemin.
18
+ * Gère les tableaux et les objets. Retourne undefined si le chemin n'est pas trouvé.
19
+ * Exemple: getNestedValue({ a: { b: [ { c: 1 } ] } }, 'a.b.0.c') -> 1
20
+ *
21
+ * @param {object} obj L'objet source.
22
+ * @param {string} path La chaîne de chemin (ex: 'user.address.city').
23
+ * @returns {*} La valeur trouvée ou undefined.
24
+ */
25
+ function getNestedValue(obj, path) {
26
+ // Vérifie si l'objet ou le chemin est invalide
27
+ if (!obj || typeof path !== 'string') {
28
+ return undefined;
29
+ }
30
+ // Sépare le chemin en clés individuelles (ex: 'a.b.0.c' -> ['a', 'b', '0', 'c'])
31
+ const keys = path.split('.');
32
+ let current = obj; // Commence à la racine de l'objet
33
+
34
+ // Parcourt chaque clé dans le chemin
35
+ for (const key of keys) {
36
+ // Si à un moment donné on atteint null ou undefined, le chemin est invalide
37
+ if (current === null || current === undefined) {
38
+ return undefined;
39
+ }
40
+ // Récupère la valeur pour la clé actuelle
41
+ const value = current[key];
42
+ // Si la valeur est undefined, le chemin est invalide
43
+ if (value === undefined) {
44
+ return undefined;
45
+ }
46
+ // Passe au niveau suivant de l'objet/tableau
47
+ current = value;
48
+ }
49
+ // Retourne la valeur finale trouvée
50
+ return current;
51
+ }
12
52
  /**
13
53
  * Evaluates a single condition against form data.
14
54
  * @param {object} currentModelDef - The definition of the current model.
@@ -272,4 +312,133 @@ export const isConditionMet = (model, cond, formData, allModels, user,checkRegex
272
312
  }
273
313
 
274
314
  return evaluateSingleCondition(model, condition, formData, allModels, user, checkRegex);
275
- };
315
+ };
316
+
317
+
318
+
319
+ /**
320
+ * Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
321
+ * Version améliorée avec support des chemins complexes via resolvePathValue.
322
+ */
323
+ export async function substituteVariables(template, contextData, user) {
324
+ // 1. Retourner les types non substituables tels quels
325
+ if (template === null || (typeof template !== 'string' && typeof template !== 'object')) {
326
+ return template;
327
+ }
328
+
329
+ // 2. Gérer les tableaux de manière récursive
330
+ if (Array.isArray(template)) {
331
+ return Promise.all(template.map(item => substituteVariables(item, contextData, user)));
332
+ }
333
+
334
+ // 3. Gérer les objets de manière récursive
335
+ if (typeof template === 'object') {
336
+ const newObj = {};
337
+ for (const key in template) {
338
+ if (Object.prototype.hasOwnProperty.call(template, key)) {
339
+ const val = await substituteVariables(template[key], contextData, user);
340
+ safeAssignObject(newObj, key, val);
341
+ }
342
+ }
343
+ return newObj;
344
+ }
345
+
346
+ // --- À partir d'ici, nous savons que `template` est une chaîne de caractères ---
347
+
348
+ // 4. Construire le contexte complet pour la substitution
349
+ const dbCollection = await getCollectionForUser(user);
350
+ const userEnvVars = await dbCollection.find({ _model: 'env', _user: user.username }).toArray();
351
+ const userEnv = userEnvVars.reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {});
352
+
353
+ // `contextToSearch` contient toutes les données disponibles à sa racine
354
+ const contextToSearch = { ...contextData, env: userEnv };
355
+
356
+ // 5. Logique de résolution de valeur améliorée avec resolvePathValue
357
+ const findValue = async (key) => {
358
+ let path = key.trim();
359
+ if (path.startsWith('context.')) {
360
+ path = path.substring('context.'.length);
361
+ }
362
+ if (path.endsWith('._id')) {
363
+ const basePath = path.slice(0, -4);
364
+ const value = await findValue(basePath);
365
+ return value?._id?.toString(); // Convertit l'ObjectId en string
366
+ }
367
+
368
+ // Gérer les valeurs dynamiques spéciales
369
+ if (path === 'now') {
370
+ return new Date().toISOString();
371
+ } else if (path === 'randomUUID') {
372
+ return crypto.randomUUID();
373
+ } else if( path === "baseUrl" ){
374
+ return process.env.NODE_ENV === 'production' ? 'https://'+getHost()+'/' : 'http://localhost:/'+port;
375
+ }
376
+
377
+ // Détecter si le chemin est complexe (contient plus d'un point)
378
+ if (path.split('.').length > 1) {
379
+ try {
380
+ // Essayer de résoudre le chemin avec resolvePathValue
381
+ const [root, ...rest] = path.split('.');
382
+ // On vérifie si la racine du chemin (ex: 'triggerData') existe dans notre contexte
383
+ if (contextToSearch[root]) {
384
+ const resolvedValue = await resolvePathValue(
385
+ rest.join('.'),
386
+ contextToSearch[root], // On passe le bon objet de départ (ex: l'objet triggerData)
387
+ user
388
+ );
389
+ if (resolvedValue !== undefined) {
390
+ return resolvedValue;
391
+ }
392
+ }
393
+ } catch (error) {
394
+ console.warn(`Erreur lors de la résolution du chemin "${path}":`, error.message);
395
+ // On continue avec la méthode normale si la résolution échoue
396
+ }
397
+ }
398
+
399
+ // Fallback: chercher le chemin dans l'objet de contexte normal
400
+ return getNestedValue(contextToSearch, path);
401
+ };
402
+
403
+ // CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
404
+ const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
405
+ if (singlePlaceholderMatch) {
406
+ const key = singlePlaceholderMatch[1];
407
+ const value = await findValue(key);
408
+
409
+ if (value === undefined) {
410
+ return template; // Placeholder not found, return as is.
411
+ }
412
+
413
+ // If the resolved value is a string, it might contain more placeholders.
414
+ // We recursively call substituteVariables on it, but only if it's different
415
+ // from the original template to prevent infinite loops.
416
+ if (typeof value === 'string' && value !== template) {
417
+ return substituteVariables(value, contextData, user);
418
+ }
419
+
420
+ // For non-string values or if value is same as template, return the value.
421
+ return value;
422
+ }
423
+
424
+ // CAS B : La chaîne contient plusieurs placeholders ou mix texte/variables
425
+ const placeholderRegex = /\{([^}]+)\}/g;
426
+ const placeholders = [...template.matchAll(placeholderRegex)];
427
+
428
+ // Si aucun placeholder trouvé, retourner la chaîne telle quelle
429
+ if (placeholders.length === 0) {
430
+ return template;
431
+ }
432
+
433
+ // Remplacer chaque placeholder de manière asynchrone
434
+ let result = template;
435
+ for (const [match, key] of placeholders) {
436
+ const value = await findValue(key);
437
+ const replacement = value !== undefined
438
+ ? (value === null ? 'null' : typeof value === 'object' ? JSON.stringify(value) : String(value))
439
+ : match;
440
+ result = result.replace(match, replacement);
441
+ }
442
+
443
+ return result;
444
+ }
@@ -391,7 +391,11 @@ export function onInit(defaultEngine) {
391
391
  }
392
392
 
393
393
  // If this is the first history record for this document, add a snapshot of the 'before' state.
394
- if (!lastVersionDoc) {
394
+ // --- FIX ---
395
+ // The check should be based on the calculated version number, not on the existence of lastVersionDoc,
396
+ // which could be affected by race conditions in a busy environment. If we are creating version 1,
397
+ // it means no prior history exists for this document, so we must add the snapshot.
398
+ if (newVersion === 1) {
395
399
  historyEntry.snapshot = beforeDoc;
396
400
  logger.debug(`History v${newVersion} (update with initial snapshot) created for ${modelName} document ${afterDoc._id}`);
397
401
  } else {
@@ -1679,9 +1679,15 @@ export const deleteData = async (modelName, filter, user = {}, triggerWorkflow,
1679
1679
  logger.info(`[deleteData] No documents to delete for user ${user?.username} after permission checks or matching criteria.`);
1680
1680
  }
1681
1681
 
1682
- const res = {success: true, deletedCount}
1683
- const plugin = await Event.Trigger("OnDataDeleted", "event", "system", engine, {model: modelName, filter});
1684
- await Event.Trigger("OnDataDeleted", "event", "user", {model: modelName, filter});
1682
+ const res = { success: true, deletedCount };
1683
+
1684
+ // --- CORRECTION ---
1685
+ // The event payload must match what the history listener expects: { modelName, user, before }.
1686
+ // 'documentsToDelete' contains the full documents before they were deleted.
1687
+ const eventPayload = { modelName: modelNameToInvalidate, user, before: documentsToDelete };
1688
+ const plugin = await Event.Trigger("OnDataDeleted", "event", "system", engine, eventPayload);
1689
+ await Event.Trigger("OnDataDeleted", "event", "user", eventPayload);
1690
+
1685
1691
  return plugin || res;
1686
1692
 
1687
1693
  } catch (error) {
@@ -15,6 +15,7 @@ import rateLimit from "express-rate-limit";
15
15
  import ivm from "isolated-vm";
16
16
  import {emailDefaultConfig} from "../constants.js";
17
17
  import {safeAssignObject} from "../core.js";
18
+ import {substituteVariables} from "../filter.js";
18
19
  import {Config} from "../config.js";
19
20
 
20
21
  export const userInitiator = async (req, res, next) => {
@@ -105,35 +106,38 @@ export async function onInit(defaultEngine) {
105
106
  * 1. Elle récupère toutes les permissions de base issues des rôles de l'utilisateur.
106
107
  * 2. Elle applique ensuite les "exceptions" (ajouts ou retraits de permissions) qui sont valides (non expirées).
107
108
  * @param {object} user - L'objet utilisateur pour lequel calculer les permissions.
108
- * @returns {Promise<Set<string>>} Un Set contenant les noms de toutes les permissions actives.
109
+ * @returns {Promise<Map<string, object|null>>} Une Map contenant les noms des permissions actives et leur filtre associé.
109
110
  * @private
110
111
  */
111
112
  export async function getUserActivePermissions(user, env = null) {
112
113
  const datasCollection = await getCollectionForUser(user);
113
114
  const now = new Date();
114
- const activePermissions = new Set();
115
+ const activePermissions = new Map();
115
116
 
116
117
  // --- ÉTAPE 1: Récupérer les permissions de base des rôles ---
117
118
  if (user.roles && user.roles.length > 0) {
118
119
  const roleIds = user.roles.map(id => new ObjectId(id));
119
-
120
+
120
121
  const rolePermissions = await datasCollection.aggregate([
121
- { $match: { _id: { $in: roleIds }, _model: "role" } },
122
+ { $match: { _id: { $in: roleIds }, _model: "role", _user: user.username } },
122
123
  { $unwind: "$permissions" },
123
- { $addFields: { "permissionId": { "$toObjectId": "$permissions" } } },
124
124
  {
125
125
  $lookup: {
126
126
  from: datasCollection.collectionName, // Utiliser la même collection
127
- localField: "permissionId",
128
- foreignField: "_id",
127
+ let: { permissionIdStr: "$permissions" }, // La permission est un string
128
+ pipeline: [
129
+ { $match: {
130
+ $expr: { $eq: [ { $toString: "$_id" }, "$$permissionIdStr" ] }
131
+ }}
132
+ ],
129
133
  as: "permissionDoc"
130
134
  }
131
135
  },
132
136
  { $unwind: "$permissionDoc" },
133
- { $group: { _id: "$permissionDoc.name" } }
137
+ { $project: { name: "$permissionDoc.name", filter: "$permissionDoc.filter" } }
134
138
  ]).toArray();
135
139
 
136
- rolePermissions.forEach(p => p._id && activePermissions.add(p._id));
140
+ rolePermissions.forEach(p => p.name && activePermissions.set(p.name, p.filter ?? null));
137
141
  }
138
142
 
139
143
  // --- ÉTAPE 2: Appliquer les exceptions de permission ---
@@ -141,7 +145,7 @@ export async function getUserActivePermissions(user, env = null) {
141
145
  {
142
146
  $match: { // Filtre de base pour les exceptions de l'utilisateur
143
147
  _model: "userPermission",
144
- user: user._id,
148
+ user: user._id.toString(),
145
149
  $and: [ // Filtre sur l'environnement et l'expiration
146
150
  {
147
151
  $or: [ // La permission est soit globale, soit spécifique à l'environnement demandé
@@ -160,20 +164,22 @@ export async function getUserActivePermissions(user, env = null) {
160
164
  pipeline: [
161
165
  {
162
166
  $match: {
163
- $expr: {
164
- $eq: [
165
- "$_id",
166
- { $toObjectId: "$$permissionId" } // Conversion ici
167
- ]
168
- }
167
+ $expr: { $eq: [ { $toString: "$_id" }, "$$permissionId" ] }
169
168
  }
170
169
  },
171
- { $project: { name: 1 } }
170
+ { $project: { name: 1, filter: 1 } }
172
171
  ],
173
172
  as: 'permissionDoc'
174
173
  }
175
174
  },
176
- { $unwind: '$permissionDoc' }
175
+ { $unwind: '$permissionDoc' },
176
+ {
177
+ $project: { // On sélectionne les champs dont on a besoin
178
+ isGranted: 1,
179
+ filter: 1, // On ajoute le filtre de l'exception
180
+ permissionDoc: 1
181
+ }
182
+ }
177
183
  ]).toArray();
178
184
 
179
185
  // Appliquer les exceptions
@@ -182,7 +188,11 @@ export async function getUserActivePermissions(user, env = null) {
182
188
  if (!permissionName) continue;
183
189
 
184
190
  if (exception.isGranted) {
185
- activePermissions.add(permissionName);
191
+ // Priorité 1: Le filtre défini sur l'exception elle-même.
192
+ // Priorité 2: Le filtre défini sur la permission de base.
193
+ // Priorité 3: null s'il n'y a aucun filtre.
194
+ const finalFilter = exception.filter ?? exception.permissionDoc.filter ?? null;
195
+ activePermissions.set(permissionName, finalFilter);
186
196
  } else {
187
197
  activePermissions.delete(permissionName);
188
198
  }
@@ -195,15 +205,19 @@ export async function getUserActivePermissions(user, env = null) {
195
205
  * Vérifie si un utilisateur possède au moins une des permissions spécifiées.
196
206
  * Cette fonction utilise la nouvelle logique basée sur les rôles et les exceptions de permission.
197
207
  * @param {string|string[]} permissionNames - Le nom de la permission ou un tableau de noms.
198
- * @param {object} user - L'objet utilisateur authentifié.
199
- * @returns {Promise<boolean>} - True si l'utilisateur a la permission, sinon false.
208
+ * @param {object} user - L'objet utilisateur.
209
+ * @param {string|null} env - L'environnement de la permission.
210
+ * @param {object|null} req - L'objet requête Express optionnel.
211
+ * @returns {Promise<boolean|object>} - `false` si aucune permission n'est trouvée. Sinon, retourne le filtre de la première permission trouvée (ou `true` si aucun filtre n'est défini).
200
212
  */
201
- export async function hasPermission(permissionNames, user, env = null) {
213
+ export async function hasPermission(permissionNames, user, env = null, req = null) {
202
214
  // Garde la compatibilité pour les utilisateurs non-locaux (ex: système)
203
215
  if (!isLocalUser(user)) {
204
216
  const userRoles = new Set(user.roles || []);
205
217
  const requiredPermissions = Array.isArray(permissionNames) ? permissionNames : [permissionNames];
206
- return requiredPermissions.some(p => userRoles.has(p));
218
+ const hasPerm = requiredPermissions.some(p => userRoles.has(p));
219
+ // Pour les non-locaux, on ne gère pas les filtres complexes, on retourne juste true/false.
220
+ return hasPerm ? true : false;
207
221
  }
208
222
 
209
223
  try {
@@ -216,9 +230,27 @@ export async function hasPermission(permissionNames, user, env = null) {
216
230
  // 1. Obtenir l'ensemble final et à jour des permissions de l'utilisateur
217
231
  const activePermissions = await getUserActivePermissions(user, env);
218
232
 
219
- // 2. Vérifier si au moins une des permissions requises est dans l'ensemble des permissions actives
220
- return requiredPermissions.some(pName => activePermissions.has(pName)) || false;
233
+ // 2. Chercher la première permission correspondante
234
+ for (const pName of requiredPermissions) {
235
+ if (activePermissions.has(pName)) {
236
+ const filter = activePermissions.get(pName);
237
+ // Si un filtre existe, on substitue les variables
238
+ if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
239
+ // Le contexte de substitution est enrichi avec des données pertinentes
240
+ const context = {
241
+ user,
242
+ permissionName: pName,
243
+ env,
244
+ now: new Date(),
245
+ req: req ? { query: req.query, body: req.body, params: req.params, ip: req.ip } : null
246
+ };
247
+ return await substituteVariables(filter, context, user);
248
+ }
249
+ return true; // Pas de filtre ou filtre vide, on autorise sans condition supplémentaire
250
+ }
251
+ }
221
252
 
253
+ return false; // Aucune permission trouvée
222
254
  } catch (e) {
223
255
  logger.error("Erreur lors de la vérification des permissions :", e);
224
256
  return false;