data-primals-engine 1.6.3 → 1.6.5

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.
@@ -10,9 +10,29 @@ let engine, logger;
10
10
  export async function onInit(defaultEngine) {
11
11
  engine = defaultEngine;
12
12
  logger = engine.getComponent(Logger);
13
-
14
- const swaggerDocument = YAML.parse(fs.readFileSync(process.cwd() + '/swagger-en.yml', 'utf8'));
15
-
16
- engine.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
17
-
13
+
14
+ const swaggerDocs = {
15
+ en: YAML.parse(fs.readFileSync(process.cwd() + '/swagger-en.yml', 'utf8')),
16
+ fr: YAML.parse(fs.readFileSync(process.cwd() + '/swagger-fr.yml', 'utf8'))
17
+ };
18
+
19
+ // Sers les assets de swagger-ui
20
+ engine.use('/api-docs', swaggerUi.serve);
21
+
22
+ // Route pour la langue par défaut (ex: /api-docs)
23
+ engine.get('/api-docs', (req, res) => {
24
+ return swaggerUi.setup(swaggerDocs['en'])(req, res);
25
+ });
26
+
27
+ // Route pour une langue spécifique (ex: /api-docs/fr)
28
+ engine.get('/api-docs/:lang', (req, res) => {
29
+ const lang = req.params.lang;
30
+
31
+ // Si la langue demandée n'existe pas, on redirige vers la version anglaise.
32
+ if (!swaggerDocs[lang]) {
33
+ return res.redirect('/api-docs/en');
34
+ }
35
+
36
+ return swaggerUi.setup(swaggerDocs[lang])(req, res);
37
+ });
18
38
  };
@@ -15,8 +15,10 @@ 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
+ import {substituteVariables} from "./workflow.js";
19
+ import NodeCache from "node-cache";
19
20
  import {Config} from "../config.js";
21
+ import { Event } from '../events.js';
20
22
 
21
23
  export const userInitiator = async (req, res, next) => {
22
24
 
@@ -99,7 +101,28 @@ let logger,engine;
99
101
  export async function onInit(defaultEngine) {
100
102
  engine = defaultEngine;
101
103
  logger = engine.getComponent(Logger);
104
+
105
+ const invalidatePermissionsCache = (engine, {modelName, insertedDocs, user}) => {
106
+ const modelsToWatch = ['role', 'permission', 'userPermission'];
107
+
108
+ // Le payload contient le nom du modèle qui a été modifié.
109
+ if (modelName && modelsToWatch.includes(modelName)) {
110
+ logger.info(`[Permissions] Invalidating permissions cache due to change in '${modelName}'.`);
111
+ permissionsCache.flushAll();
112
+ }
113
+ };
114
+
115
+ // On attache notre fonction d'invalidation aux événements système.
116
+ Event.Listen("OnDataAdded", invalidatePermissionsCache, "event", "system");
117
+ Event.Listen("OnDataEdited", invalidatePermissionsCache, "event", "system");
118
+ Event.Listen("OnDataDeleted", invalidatePermissionsCache, "event", "system");
102
119
  }
120
+ /**
121
+ * Cache pour les permissions des utilisateurs.
122
+ * TTL de 10 minutes, vérification toutes les 2 minutes.
123
+ */
124
+ const permissionsCache = new NodeCache({ stdTTL: 600, checkperiod: 120, useClones: false });
125
+
103
126
  /**
104
127
  * Calcule et retourne l'ensemble des permissions actives pour un utilisateur.
105
128
  * Cette fonction interne est la pierre angulaire de la nouvelle logique de permission.
@@ -110,6 +133,14 @@ export async function onInit(defaultEngine) {
110
133
  * @private
111
134
  */
112
135
  export async function getUserActivePermissions(user, env = null) {
136
+ // Clé de cache unique pour l'utilisateur et l'environnement
137
+ const cacheKey = `${user._user || user.username}:${user._id.toString()}:${env || 'global'}`;
138
+ const cachedPermissions = permissionsCache.get(cacheKey);
139
+ if (cachedPermissions) {
140
+ logger.debug(`[Permissions] Cache hit for user ${user.username}`);
141
+ return cachedPermissions;
142
+ }
143
+ logger.debug(`[Permissions] Cache miss for user ${user.username}. Calculating permissions...`);
113
144
  const datasCollection = await getCollectionForUser(user);
114
145
  const now = new Date();
115
146
  const activePermissions = new Map();
@@ -117,88 +148,87 @@ export async function getUserActivePermissions(user, env = null) {
117
148
  // --- ÉTAPE 1: Récupérer les permissions de base des rôles ---
118
149
  if (user.roles && user.roles.length > 0) {
119
150
  const roleIds = user.roles.map(id => new ObjectId(id));
120
-
121
- const rolePermissions = await datasCollection.aggregate([
122
- { $match: { _id: { $in: roleIds }, _model: "role", _user: user.username } },
123
- { $unwind: "$permissions" },
124
- {
125
- $lookup: {
126
- from: datasCollection.collectionName, // Utiliser la même collection
127
- let: { permissionIdStr: "$permissions" }, // La permission est un string
128
- pipeline: [
129
- { $match: {
130
- $expr: { $eq: [ { $toString: "$_id" }, "$$permissionIdStr" ] }
131
- }}
132
- ],
133
- as: "permissionDoc"
134
- }
135
- },
136
- { $unwind: "$permissionDoc" },
137
- { $project: { name: "$permissionDoc.name", filter: "$permissionDoc.filter" } }
138
- ]).toArray();
139
151
 
140
- rolePermissions.forEach(p => p.name && activePermissions.set(p.name, p.filter ?? null));
152
+ // Étape 1: Récupérer tous les IDs de permission des rôles de l'utilisateur.
153
+ const roles = await datasCollection.find(
154
+ { _id: { $in: roleIds }, _model: "role", _user: user._user || user.username },
155
+ { projection: { permissions: 1, _user: 1 } }
156
+ ).toArray();
157
+
158
+ // Aplatir tous les tableaux de permissions en un seul et supprimer les doublons.
159
+ const permissionIdStrings = [...new Set(roles.flatMap(role => role.permissions || []))];
160
+ const permissionObjectIds = permissionIdStrings.map(id => new ObjectId(id));
161
+
162
+ // Étape 2: Récupérer tous les documents de permission correspondants en une seule requête.
163
+ const rolePermissions = await datasCollection.find(
164
+ { _id: { $in: permissionObjectIds }, _model: "permission", _user: user._user || user.username },
165
+ { projection: { name: 1, filter: 1 } }
166
+ ).toArray();
167
+
168
+ rolePermissions.forEach(p => {
169
+ if (p.name) {
170
+ // On stocke l'objet permission complet pour un accès ultérieur
171
+ activePermissions.set(p.name, { filter: p.filter ?? null, details: p });
172
+ }
173
+ });
141
174
  }
142
175
 
176
+ // --- NOUVELLE ÉTAPE : Pré-charger les détails de toutes les permissions d'exception ---
177
+ // Cela garantit que nous avons les noms de permission même pour les révocations.
178
+ const exceptionPermissionIds = (await datasCollection.distinct("permission", {
179
+ _model: "userPermission",
180
+ user: user._id.toString(),
181
+ _user: user._user || user.username
182
+ })).map(id => new ObjectId(id));
183
+
184
+ const exceptionPermsDetails = await datasCollection.find(
185
+ { _id: { $in: exceptionPermissionIds }, _model: "permission" },
186
+ { projection: { name: 1, filter: 1 } }
187
+ ).toArray();
188
+
189
+ const allPermDetailsMap = new Map(exceptionPermsDetails.map(p => [p._id.toString(), p]));
190
+
143
191
  // --- ÉTAPE 2: Appliquer les exceptions de permission ---
144
- const exceptions = await datasCollection.aggregate([
145
- {
146
- $match: { // Filtre de base pour les exceptions de l'utilisateur
147
- _model: "userPermission",
148
- user: user._id.toString(),
149
- $and: [ // Filtre sur l'environnement et l'expiration
150
- {
151
- $or: [ // La permission est soit globale, soit spécifique à l'environnement demandé
152
- { env: { $exists: false } },
153
- { env: env }
154
- ]
155
- },
156
- { $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: now } }] }
157
- ]
158
- }
159
- },
160
- {
161
- $lookup: {
162
- from: datasCollection.collectionName,
163
- let: { permissionId: "$permission" },
164
- pipeline: [
165
- {
166
- $match: {
167
- $expr: { $eq: [ { $toString: "$_id" }, "$$permissionId" ] }
168
- }
169
- },
170
- { $project: { name: 1, filter: 1 } }
171
- ],
172
- as: 'permissionDoc'
173
- }
174
- },
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
192
+ const exceptionsQuery = {
193
+ _model: "userPermission",
194
+ user: user._id.toString(),
195
+ _user: user._user || user.username,
196
+ $and: [
197
+ { $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: now } }] },
198
+ { $or: [{ env: { $exists: false } }, { env: env }] }
199
+ ]
200
+ };
201
+
202
+ const exceptions = await datasCollection.find(exceptionsQuery, {
203
+ projection: { permission: 1, isGranted: 1, filter: 1 } // Le filtre sur l'exception
204
+ }).toArray();
205
+
206
+ if (exceptions.length > 0) {
207
+ for (const exception of exceptions) {
208
+ // Utiliser la map pré-chargée qui contient toutes les permissions d'exception
209
+ const permDetails = allPermDetailsMap.get(exception.permission);
210
+ if (!permDetails?.name) continue;
211
+
212
+ if (exception.isGranted) {
213
+ // Priorité 1: Le filtre défini sur l'exception elle-même.
214
+ // Priorité 2: Le filtre défini sur la permission de base.
215
+ const finalFilter = exception.filter ?? permDetails.filter ?? null;
216
+ // On met à jour l'entrée existante ou on en crée une nouvelle
217
+ activePermissions.set(permDetails.name, { filter: finalFilter, details: permDetails });
218
+ } else {
219
+ activePermissions.delete(permDetails.name);
181
220
  }
182
221
  }
183
- ]).toArray();
184
-
185
- // Appliquer les exceptions
186
- for (const exception of exceptions) {
187
- const permissionName = exception.permissionDoc?.name;
188
- if (!permissionName) continue;
189
-
190
- if (exception.isGranted) {
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);
196
- } else {
197
- activePermissions.delete(permissionName);
198
- }
199
222
  }
200
223
 
201
- return activePermissions;
224
+ // Mettre le résultat en cache avant de le retourner
225
+ // On ne stocke que le nom et le filtre, pas l'objet 'details' complet
226
+ const finalPermissionsToCache = new Map();
227
+ for (const [name, permData] of activePermissions.entries()) {
228
+ finalPermissionsToCache.set(name, permData.filter);
229
+ }
230
+ permissionsCache.set(cacheKey, finalPermissionsToCache);
231
+ return finalPermissionsToCache;
202
232
  }
203
233
 
204
234
  /**
@@ -221,15 +251,15 @@ export async function hasPermission(permissionNames, user, env = null, req = nul
221
251
  }
222
252
 
223
253
  try {
254
+ // 1. Obtenir l'ensemble final et à jour des permissions de l'utilisateur
255
+ const activePermissions = await getUserActivePermissions(user, env);
256
+
224
257
  const requiredPermissions = Array.isArray(permissionNames) ? permissionNames : [permissionNames];
225
258
  // Si aucune permission n'est requise, on autorise
226
259
  if (requiredPermissions.length === 0) {
227
260
  return true;
228
261
  }
229
262
 
230
- // 1. Obtenir l'ensemble final et à jour des permissions de l'utilisateur
231
- const activePermissions = await getUserActivePermissions(user, env);
232
-
233
263
  // 2. Chercher la première permission correspondante
234
264
  for (const pName of requiredPermissions) {
235
265
  if (activePermissions.has(pName)) {
@@ -18,7 +18,144 @@ import {getEnv, getSmtpConfig} from "./user.js";
18
18
  import { providers } from "./assistant/constants.js";
19
19
  import { getAIProvider } from "./assistant/providers.js";
20
20
  import {Config} from "../config.js";
21
- import {substituteVariables} from "../filter.js";
21
+ import {safeAssignObject} from "../core.js";
22
+
23
+ /**
24
+ * Récupère une valeur imbriquée dans un objet (ex: 'user.address.city').
25
+ */
26
+ const getNestedValue = (obj, path) => {
27
+ if (!path || !obj) return undefined;
28
+ return path.split('.').reduce((acc, part) => acc && acc[part] !== undefined ? acc[part] : undefined, obj);
29
+ };
30
+
31
+
32
+ /**
33
+ * Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
34
+ * Version améliorée avec support des chemins complexes via resolvePathValue.
35
+ */
36
+ export async function substituteVariables(template, contextData, user) {
37
+ // 1. Retourner les types non substituables tels quels
38
+ if (template === null || (typeof template !== 'string' && typeof template !== 'object')) {
39
+ return template;
40
+ }
41
+
42
+ // 2. Gérer les tableaux de manière récursive
43
+ if (Array.isArray(template)) {
44
+ return Promise.all(template.map(item => substituteVariables(item, contextData, user)));
45
+ }
46
+
47
+ // 3. Gérer les objets de manière récursive
48
+ if (typeof template === 'object') {
49
+ const newObj = {};
50
+ for (const key in template) {
51
+ if (Object.prototype.hasOwnProperty.call(template, key)) {
52
+ const val = await substituteVariables(template[key], contextData, user);
53
+ safeAssignObject(newObj, key, val);
54
+ }
55
+ }
56
+ return newObj;
57
+ }
58
+
59
+ // --- À partir d'ici, nous savons que `template` est une chaîne de caractères ---
60
+
61
+ // 4. Construire le contexte complet pour la substitution
62
+ const dbCollection = await getCollectionForUser(user);
63
+ const userEnvVars = await dbCollection.find({ _model: 'env', _user: user.username }).toArray();
64
+ const userEnv = userEnvVars.reduce((acc, v) => ({ ...acc, [v.name]: v.value }), {});
65
+
66
+ // `contextToSearch` contient toutes les données disponibles à sa racine
67
+ const contextToSearch = { ...contextData, env: userEnv };
68
+
69
+ // 5. Logique de résolution de valeur améliorée avec resolvePathValue
70
+ const findValue = async (key) => {
71
+ let path = key.trim();
72
+ if (path.startsWith('context.')) {
73
+ path = path.substring('context.'.length);
74
+ }
75
+ if (path.endsWith('._id')) {
76
+ const basePath = path.slice(0, -4);
77
+ const value = await findValue(basePath);
78
+ return value?._id?.toString(); // Convertit l'ObjectId en string
79
+ }
80
+
81
+ // Gérer les valeurs dynamiques spéciales
82
+ if (path === 'now') {
83
+ return new Date().toISOString();
84
+ } else if (path === 'randomUUID') {
85
+ return crypto.randomUUID();
86
+ } else if( path === "baseUrl" ){
87
+ return process.env.NODE_ENV === 'production' ? 'https://'+getHost()+'/' : 'http://localhost:/'+port;
88
+ }
89
+
90
+ // Détecter si le chemin est complexe (contient plus d'un point)
91
+ if (path.split('.').length > 1) {
92
+ try {
93
+ // Essayer de résoudre le chemin avec resolvePathValue
94
+ const [root, ...rest] = path.split('.');
95
+ // On vérifie si la racine du chemin (ex: 'triggerData') existe dans notre contexte
96
+ if (contextToSearch[root]) {
97
+ const resolvedValue = await resolvePathValue(
98
+ rest.join('.'),
99
+ contextToSearch[root], // On passe le bon objet de départ (ex: l'objet triggerData)
100
+ user
101
+ );
102
+ if (resolvedValue !== undefined) {
103
+ return resolvedValue;
104
+ }
105
+ }
106
+ } catch (error) {
107
+ console.warn(`Erreur lors de la résolution du chemin "${path}":`, error.message);
108
+ // On continue avec la méthode normale si la résolution échoue
109
+ }
110
+ }
111
+
112
+ // Fallback: chercher le chemin dans l'objet de contexte normal
113
+ return getNestedValue(contextToSearch, path);
114
+ };
115
+
116
+ // CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
117
+ const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
118
+ if (singlePlaceholderMatch) {
119
+ const key = singlePlaceholderMatch[1];
120
+ const value = await findValue(key);
121
+
122
+ if (value === undefined) {
123
+ return template; // Placeholder not found, return as is.
124
+ }
125
+
126
+ // If the resolved value is a string, it might contain more placeholders.
127
+ // We recursively call substituteVariables on it, but only if it's different
128
+ // from the original template to prevent infinite loops.
129
+ if (typeof value === 'string' && value !== template) {
130
+ return substituteVariables(value, contextData, user);
131
+ }
132
+
133
+ // For non-string values or if value is same as template, return the value.
134
+ return value;
135
+ }
136
+
137
+ // CAS B : La chaîne contient plusieurs placeholders ou mix texte/variables
138
+ const placeholderRegex = /\{([^}]+)\}/g;
139
+ const placeholders = [...template.matchAll(placeholderRegex)];
140
+
141
+ // Si aucun placeholder trouvé, retourner la chaîne telle quelle
142
+ if (placeholders.length === 0) {
143
+ return template;
144
+ }
145
+
146
+ // Remplacer chaque placeholder de manière asynchrone
147
+ let result = template;
148
+ for (const [match, key] of placeholders) {
149
+ const value = await findValue(key);
150
+ const replacement = value !== undefined
151
+ ? (value === null ? 'null' : typeof value === 'object' ? JSON.stringify(value) : String(value))
152
+ : match;
153
+ result = result.replace(match, replacement);
154
+ }
155
+
156
+ return result;
157
+ }
158
+
22
159
 
23
160
  let logger = null;
24
161
  export async function onInit(defaultEngine) {
@@ -27,8 +164,6 @@ export async function onInit(defaultEngine) {
27
164
  await scheduleWorkflowTriggers();
28
165
  }
29
166
 
30
- export { substituteVariables } from "../filter.js";
31
-
32
167
  /**
33
168
  * Déclenche un workflow par son nom et lui passe des données de contexte.
34
169
  * C'est la fonction clé à exposer aux endpoints pour lancer des processus métier.