@intranefr/superbackend 1.5.0 → 1.5.2

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.
Files changed (198) hide show
  1. package/.env.example +15 -0
  2. package/README.md +11 -0
  3. package/analysis-only.skill +0 -0
  4. package/index.js +23 -0
  5. package/package.json +8 -2
  6. package/src/admin/endpointRegistry.js +120 -0
  7. package/src/controllers/admin.controller.js +90 -6
  8. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  9. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  10. package/src/controllers/adminCache.controller.js +342 -0
  11. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  12. package/src/controllers/adminCrons.controller.js +388 -0
  13. package/src/controllers/adminDbBrowser.controller.js +124 -0
  14. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  15. package/src/controllers/adminExperiments.controller.js +200 -0
  16. package/src/controllers/adminHeadless.controller.js +9 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +126 -4
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/blogAdmin.controller.js +279 -0
  28. package/src/controllers/blogAiAdmin.controller.js +224 -0
  29. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  30. package/src/controllers/blogInternal.controller.js +26 -0
  31. package/src/controllers/blogPublic.controller.js +89 -0
  32. package/src/controllers/experiments.controller.js +85 -0
  33. package/src/controllers/fileManager.controller.js +190 -0
  34. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  35. package/src/controllers/healthChecksPublic.controller.js +196 -0
  36. package/src/controllers/internalExperiments.controller.js +17 -0
  37. package/src/controllers/metrics.controller.js +64 -4
  38. package/src/controllers/orgAdmin.controller.js +80 -0
  39. package/src/helpers/mongooseHelper.js +258 -0
  40. package/src/helpers/scriptBase.js +230 -0
  41. package/src/helpers/scriptRunner.js +335 -0
  42. package/src/middleware/rbac.js +62 -0
  43. package/src/middleware.js +810 -48
  44. package/src/models/BlockDefinition.js +27 -0
  45. package/src/models/BlogAutomationLock.js +14 -0
  46. package/src/models/BlogAutomationRun.js +39 -0
  47. package/src/models/BlogPost.js +42 -0
  48. package/src/models/CacheEntry.js +26 -0
  49. package/src/models/ConsoleEntry.js +32 -0
  50. package/src/models/ConsoleLog.js +23 -0
  51. package/src/models/ContextBlockDefinition.js +33 -0
  52. package/src/models/CronExecution.js +47 -0
  53. package/src/models/CronJob.js +70 -0
  54. package/src/models/Experiment.js +75 -0
  55. package/src/models/ExperimentAssignment.js +23 -0
  56. package/src/models/ExperimentEvent.js +26 -0
  57. package/src/models/ExperimentMetricBucket.js +30 -0
  58. package/src/models/ExternalDbConnection.js +49 -0
  59. package/src/models/FileEntry.js +22 -0
  60. package/src/models/GlobalSetting.js +1 -2
  61. package/src/models/HealthAutoHealAttempt.js +57 -0
  62. package/src/models/HealthCheck.js +132 -0
  63. package/src/models/HealthCheckRun.js +51 -0
  64. package/src/models/HealthIncident.js +49 -0
  65. package/src/models/Page.js +95 -0
  66. package/src/models/PageCollection.js +42 -0
  67. package/src/models/ProxyEntry.js +66 -0
  68. package/src/models/RateLimitCounter.js +19 -0
  69. package/src/models/RateLimitMetricBucket.js +20 -0
  70. package/src/models/RbacGrant.js +25 -0
  71. package/src/models/RbacGroup.js +16 -0
  72. package/src/models/RbacGroupMember.js +13 -0
  73. package/src/models/RbacGroupRole.js +13 -0
  74. package/src/models/RbacRole.js +25 -0
  75. package/src/models/RbacUserRole.js +13 -0
  76. package/src/models/ScriptDefinition.js +1 -0
  77. package/src/models/Webhook.js +2 -0
  78. package/src/routes/admin.routes.js +2 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminExperiments.routes.js +29 -0
  88. package/src/routes/adminHeadless.routes.js +2 -1
  89. package/src/routes/adminHealthChecks.routes.js +28 -0
  90. package/src/routes/adminI18n.routes.js +4 -3
  91. package/src/routes/adminLlm.routes.js +4 -2
  92. package/src/routes/adminPages.routes.js +55 -0
  93. package/src/routes/adminProxy.routes.js +15 -0
  94. package/src/routes/adminRateLimits.routes.js +17 -0
  95. package/src/routes/adminRbac.routes.js +38 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminUiComponents.routes.js +2 -1
  98. package/src/routes/blogInternal.routes.js +14 -0
  99. package/src/routes/blogPublic.routes.js +9 -0
  100. package/src/routes/experiments.routes.js +30 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/internalExperiments.routes.js +15 -0
  105. package/src/routes/log.routes.js +43 -60
  106. package/src/routes/metrics.routes.js +4 -2
  107. package/src/routes/orgAdmin.routes.js +1 -0
  108. package/src/routes/pages.routes.js +123 -0
  109. package/src/routes/proxy.routes.js +46 -0
  110. package/src/routes/rbac.routes.js +47 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +185 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +738 -0
  120. package/src/services/consoleOverride.service.js +7 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/experiments.service.js +273 -0
  125. package/src/services/experimentsAggregation.service.js +308 -0
  126. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  127. package/src/services/experimentsRetention.service.js +43 -0
  128. package/src/services/experimentsWs.service.js +134 -0
  129. package/src/services/fileManager.service.js +475 -0
  130. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  131. package/src/services/globalSettings.service.js +15 -0
  132. package/src/services/healthChecks.service.js +650 -0
  133. package/src/services/healthChecksBootstrap.service.js +109 -0
  134. package/src/services/healthChecksScheduler.service.js +106 -0
  135. package/src/services/jsonConfigs.service.js +2 -2
  136. package/src/services/llmDefaults.service.js +190 -0
  137. package/src/services/migrationAssets/s3.js +2 -2
  138. package/src/services/pages.service.js +602 -0
  139. package/src/services/pagesContext.service.js +331 -0
  140. package/src/services/pagesContextBlocksAi.service.js +349 -0
  141. package/src/services/proxy.service.js +535 -0
  142. package/src/services/rateLimiter.service.js +623 -0
  143. package/src/services/rbac.service.js +212 -0
  144. package/src/services/scriptsRunner.service.js +215 -15
  145. package/src/services/uiComponentsAi.service.js +6 -19
  146. package/src/services/workflow.service.js +23 -8
  147. package/src/utils/orgRoles.js +14 -0
  148. package/src/utils/rbac/engine.js +60 -0
  149. package/src/utils/rbac/rightsRegistry.js +33 -0
  150. package/views/admin-blog-automation.ejs +877 -0
  151. package/views/admin-blog-edit.ejs +542 -0
  152. package/views/admin-blog.ejs +399 -0
  153. package/views/admin-cache.ejs +681 -0
  154. package/views/admin-console-manager.ejs +680 -0
  155. package/views/admin-crons.ejs +645 -0
  156. package/views/admin-dashboard.ejs +28 -8
  157. package/views/admin-db-browser.ejs +445 -0
  158. package/views/admin-ejs-virtual.ejs +16 -10
  159. package/views/admin-experiments.ejs +91 -0
  160. package/views/admin-file-manager.ejs +942 -0
  161. package/views/admin-health-checks.ejs +725 -0
  162. package/views/admin-i18n.ejs +59 -5
  163. package/views/admin-llm.ejs +99 -1
  164. package/views/admin-organizations.ejs +163 -1
  165. package/views/admin-pages.ejs +2424 -0
  166. package/views/admin-proxy.ejs +491 -0
  167. package/views/admin-rate-limiter.ejs +625 -0
  168. package/views/admin-rbac.ejs +1331 -0
  169. package/views/admin-scripts.ejs +597 -3
  170. package/views/admin-seo-config.ejs +61 -7
  171. package/views/admin-ui-components.ejs +57 -25
  172. package/views/admin-workflows.ejs +7 -7
  173. package/views/file-manager.ejs +866 -0
  174. package/views/pages/blocks/contact.ejs +27 -0
  175. package/views/pages/blocks/cta.ejs +18 -0
  176. package/views/pages/blocks/faq.ejs +20 -0
  177. package/views/pages/blocks/features.ejs +19 -0
  178. package/views/pages/blocks/hero.ejs +13 -0
  179. package/views/pages/blocks/html.ejs +5 -0
  180. package/views/pages/blocks/image.ejs +14 -0
  181. package/views/pages/blocks/testimonials.ejs +26 -0
  182. package/views/pages/blocks/text.ejs +10 -0
  183. package/views/pages/layouts/default.ejs +51 -0
  184. package/views/pages/layouts/minimal.ejs +42 -0
  185. package/views/pages/layouts/sidebar.ejs +54 -0
  186. package/views/pages/partials/footer.ejs +13 -0
  187. package/views/pages/partials/header.ejs +12 -0
  188. package/views/pages/partials/sidebar.ejs +8 -0
  189. package/views/pages/runtime/page.ejs +10 -0
  190. package/views/pages/templates/article.ejs +20 -0
  191. package/views/pages/templates/default.ejs +12 -0
  192. package/views/pages/templates/landing.ejs +14 -0
  193. package/views/pages/templates/listing.ejs +15 -0
  194. package/views/partials/admin-image-upload-modal.ejs +221 -0
  195. package/views/partials/dashboard/nav-items.ejs +12 -0
  196. package/views/partials/dashboard/palette.ejs +5 -3
  197. package/views/partials/llm-provider-model-picker.ejs +183 -0
  198. package/src/routes/llmUi.routes.js +0 -26
@@ -0,0 +1,803 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const User = require('../models/User');
4
+ const Organization = require('../models/Organization');
5
+ const OrganizationMember = require('../models/OrganizationMember');
6
+ const RbacRole = require('../models/RbacRole');
7
+ const RbacUserRole = require('../models/RbacUserRole');
8
+ const RbacGroup = require('../models/RbacGroup');
9
+ const RbacGroupMember = require('../models/RbacGroupMember');
10
+ const RbacGroupRole = require('../models/RbacGroupRole');
11
+ const RbacGrant = require('../models/RbacGrant');
12
+ const rbacService = require('../services/rbac.service');
13
+ const { listRights } = require('../utils/rbac/rightsRegistry');
14
+ const { createAuditEvent, getBasicAuthActor } = require('../services/audit.service');
15
+
16
+ function isValidObjectId(id) {
17
+ return id && mongoose.Types.ObjectId.isValid(String(id));
18
+ }
19
+
20
+ function parseLimit(value) {
21
+ const parsed = parseInt(value, 10);
22
+ if (!Number.isFinite(parsed)) return 20;
23
+ return Math.max(1, Math.min(50, parsed));
24
+ }
25
+
26
+ function escapeRegex(str) {
27
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+ }
29
+
30
+ exports.listRights = async (req, res) => {
31
+ return res.json({ rights: listRights() });
32
+ };
33
+
34
+ exports.searchUsers = async (req, res) => {
35
+ const { q, limit, orgId } = req.query;
36
+ const l = parseLimit(limit);
37
+
38
+ if (orgId !== undefined && orgId !== null && String(orgId).trim()) {
39
+ if (!isValidObjectId(orgId)) {
40
+ return res.status(400).json({ error: 'Invalid orgId' });
41
+ }
42
+
43
+ const pattern = q ? escapeRegex(String(q).trim()) : null;
44
+
45
+ const pipeline = [
46
+ { $match: { orgId: new mongoose.Types.ObjectId(String(orgId)), status: 'active' } },
47
+ {
48
+ $lookup: {
49
+ from: 'users',
50
+ let: { uid: '$userId' },
51
+ pipeline: [
52
+ { $match: { $expr: { $eq: ['$_id', '$$uid'] } } },
53
+ ...(pattern
54
+ ? [
55
+ {
56
+ $match: {
57
+ $or: [
58
+ { email: { $regex: pattern, $options: 'i' } },
59
+ { name: { $regex: pattern, $options: 'i' } },
60
+ ],
61
+ },
62
+ },
63
+ ]
64
+ : []),
65
+ { $project: { email: 1, name: 1, role: 1, createdAt: 1 } },
66
+ ],
67
+ as: 'user',
68
+ },
69
+ },
70
+ { $unwind: '$user' },
71
+ { $sort: { 'user.createdAt': -1 } },
72
+ { $limit: l },
73
+ { $project: { _id: 0, user: 1 } },
74
+ ];
75
+
76
+ const rows = await OrganizationMember.aggregate(pipeline);
77
+ const users = rows.map((r) => r.user).filter(Boolean);
78
+
79
+ return res.json({
80
+ users: users.map((u) => ({
81
+ id: String(u._id),
82
+ email: u.email,
83
+ name: u.name || '',
84
+ role: u.role,
85
+ })),
86
+ });
87
+ }
88
+
89
+ const query = {};
90
+ if (q) {
91
+ const pattern = escapeRegex(String(q).trim());
92
+ query.$or = [
93
+ { email: { $regex: pattern, $options: 'i' } },
94
+ { name: { $regex: pattern, $options: 'i' } },
95
+ ];
96
+ }
97
+
98
+ const users = await User.find(query)
99
+ .select('email name role createdAt')
100
+ .sort({ createdAt: -1 })
101
+ .limit(l)
102
+ .lean();
103
+
104
+ return res.json({
105
+ users: users.map((u) => ({
106
+ id: String(u._id),
107
+ email: u.email,
108
+ name: u.name || '',
109
+ role: u.role,
110
+ })),
111
+ });
112
+ };
113
+
114
+ exports.getUserOrgs = async (req, res) => {
115
+ const { userId } = req.params;
116
+ if (!isValidObjectId(userId)) {
117
+ return res.status(400).json({ error: 'Invalid userId' });
118
+ }
119
+
120
+ const rows = await OrganizationMember.find({ userId, status: 'active' }).select('orgId').lean();
121
+ const orgIds = rows.map((r) => r.orgId);
122
+
123
+ const orgs = await Organization.find({ _id: { $in: orgIds }, status: 'active' })
124
+ .select('name slug')
125
+ .sort({ name: 1 })
126
+ .lean();
127
+
128
+ return res.json({
129
+ orgs: orgs.map((o) => ({ id: String(o._id), name: o.name, slug: o.slug })),
130
+ });
131
+ };
132
+
133
+ exports.testRight = async (req, res) => {
134
+ const { userId, orgId, right } = req.body || {};
135
+ if (!isValidObjectId(userId) || !isValidObjectId(orgId)) {
136
+ return res.status(400).json({ error: 'userId and orgId are required (ObjectId)' });
137
+ }
138
+ if (!right || typeof right !== 'string') {
139
+ return res.status(400).json({ error: 'right is required' });
140
+ }
141
+
142
+ const result = await rbacService.checkRight({ userId, orgId, right });
143
+ return res.json({
144
+ allowed: result.allowed,
145
+ reason: result.reason,
146
+ decisionLayer: result.decisionLayer || null,
147
+ explain: result.explain || [],
148
+ context: result.context || null,
149
+ });
150
+ };
151
+
152
+ exports.listRoles = async (req, res) => {
153
+ const roles = await RbacRole.find({}).sort({ createdAt: -1 }).lean();
154
+ return res.json({
155
+ roles: roles.map((r) => ({
156
+ ...r,
157
+ id: String(r._id),
158
+ orgId: r.orgId ? String(r.orgId) : null,
159
+ })),
160
+ });
161
+ };
162
+
163
+ exports.createRole = async (req, res) => {
164
+ const { key, name, description, status, isGlobal, orgId } = req.body || {};
165
+ if (!key || !name) {
166
+ return res.status(400).json({ error: 'key and name are required' });
167
+ }
168
+
169
+ const globalFlag = isGlobal !== false;
170
+ if (!globalFlag && !isValidObjectId(orgId)) {
171
+ return res.status(400).json({ error: 'orgId is required for org-scoped roles' });
172
+ }
173
+
174
+ const actor = getBasicAuthActor(req);
175
+
176
+ const role = await RbacRole.create({
177
+ key: String(key).trim().toLowerCase(),
178
+ name: String(name).trim(),
179
+ description: String(description || '').trim(),
180
+ status: status === 'disabled' ? 'disabled' : 'active',
181
+ isGlobal: globalFlag,
182
+ orgId: globalFlag ? null : orgId,
183
+ });
184
+
185
+ await createAuditEvent({
186
+ ...actor,
187
+ action: 'admin.rbac.role.create',
188
+ entityType: 'RbacRole',
189
+ entityId: String(role._id),
190
+ before: null,
191
+ after: role.toJSON ? role.toJSON() : role,
192
+ meta: null,
193
+ });
194
+
195
+ return res.status(201).json({ role: { ...role.toObject(), id: String(role._id) } });
196
+ };
197
+
198
+ exports.updateRole = async (req, res) => {
199
+ const { id } = req.params;
200
+ if (!isValidObjectId(id)) {
201
+ return res.status(400).json({ error: 'Invalid role id' });
202
+ }
203
+
204
+ const role = await RbacRole.findById(id);
205
+ if (!role) {
206
+ return res.status(404).json({ error: 'Role not found' });
207
+ }
208
+
209
+ const before = role.toObject();
210
+ const actor = getBasicAuthActor(req);
211
+
212
+ const { name, description, status, isGlobal, orgId } = req.body || {};
213
+ if (name !== undefined) role.name = String(name).trim();
214
+ if (description !== undefined) role.description = String(description).trim();
215
+ if (status !== undefined) role.status = status === 'disabled' ? 'disabled' : 'active';
216
+
217
+ if (isGlobal !== undefined) {
218
+ const globalFlag = isGlobal !== false;
219
+ role.isGlobal = globalFlag;
220
+ if (!globalFlag) {
221
+ if (!isValidObjectId(orgId)) {
222
+ return res.status(400).json({ error: 'orgId is required for org-scoped roles' });
223
+ }
224
+ role.orgId = orgId;
225
+ } else {
226
+ role.orgId = null;
227
+ }
228
+ }
229
+
230
+ await role.save();
231
+
232
+ await createAuditEvent({
233
+ ...actor,
234
+ action: 'admin.rbac.role.update',
235
+ entityType: 'RbacRole',
236
+ entityId: String(role._id),
237
+ before,
238
+ after: role.toObject(),
239
+ meta: null,
240
+ });
241
+
242
+ return res.json({ role: { ...role.toObject(), id: String(role._id) } });
243
+ };
244
+
245
+ exports.listGroups = async (req, res) => {
246
+ const groups = await RbacGroup.find({}).sort({ createdAt: -1 }).lean();
247
+ return res.json({
248
+ groups: groups.map((g) => ({
249
+ ...g,
250
+ id: String(g._id),
251
+ orgId: g.orgId ? String(g.orgId) : null,
252
+ })),
253
+ });
254
+ };
255
+
256
+ exports.createGroup = async (req, res) => {
257
+ const { name, description, isGlobal, orgId, status } = req.body || {};
258
+ if (!name) {
259
+ return res.status(400).json({ error: 'name is required' });
260
+ }
261
+
262
+ let resolvedOrgId = null;
263
+ const globalFlag = isGlobal !== false;
264
+ if (!globalFlag) {
265
+ if (!isValidObjectId(orgId)) {
266
+ return res.status(400).json({ error: 'orgId is required for org-scoped groups' });
267
+ }
268
+ resolvedOrgId = orgId;
269
+ }
270
+
271
+ const actor = getBasicAuthActor(req);
272
+
273
+ const group = await RbacGroup.create({
274
+ name: String(name).trim(),
275
+ description: String(description || '').trim(),
276
+ status: status === 'disabled' ? 'disabled' : 'active',
277
+ isGlobal: globalFlag,
278
+ orgId: resolvedOrgId,
279
+ });
280
+
281
+ await createAuditEvent({
282
+ ...actor,
283
+ action: 'admin.rbac.group.create',
284
+ entityType: 'RbacGroup',
285
+ entityId: String(group._id),
286
+ before: null,
287
+ after: group.toObject(),
288
+ meta: null,
289
+ });
290
+
291
+ return res.status(201).json({ group: { ...group.toObject(), id: String(group._id) } });
292
+ };
293
+
294
+ exports.updateGroup = async (req, res) => {
295
+ const { id } = req.params;
296
+ if (!isValidObjectId(id)) {
297
+ return res.status(400).json({ error: 'Invalid group id' });
298
+ }
299
+
300
+ const group = await RbacGroup.findById(id);
301
+ if (!group) {
302
+ return res.status(404).json({ error: 'Group not found' });
303
+ }
304
+
305
+ const before = group.toObject();
306
+ const actor = getBasicAuthActor(req);
307
+
308
+ const { name, description, isGlobal, orgId, status } = req.body || {};
309
+ if (name !== undefined) group.name = String(name).trim();
310
+ if (description !== undefined) group.description = String(description).trim();
311
+ if (status !== undefined) group.status = status === 'disabled' ? 'disabled' : 'active';
312
+
313
+ if (isGlobal !== undefined) {
314
+ const globalFlag = isGlobal !== false;
315
+ group.isGlobal = globalFlag;
316
+ if (!globalFlag) {
317
+ if (!isValidObjectId(orgId)) {
318
+ return res.status(400).json({ error: 'orgId is required for org-scoped groups' });
319
+ }
320
+ group.orgId = orgId;
321
+ } else {
322
+ group.orgId = null;
323
+ }
324
+ }
325
+
326
+ await group.save();
327
+
328
+ await createAuditEvent({
329
+ ...actor,
330
+ action: 'admin.rbac.group.update',
331
+ entityType: 'RbacGroup',
332
+ entityId: String(group._id),
333
+ before,
334
+ after: group.toObject(),
335
+ meta: null,
336
+ });
337
+
338
+ return res.json({ group: { ...group.toObject(), id: String(group._id) } });
339
+ };
340
+
341
+ exports.listGroupMembers = async (req, res) => {
342
+ const { id } = req.params;
343
+ if (!isValidObjectId(id)) {
344
+ return res.status(400).json({ error: 'Invalid group id' });
345
+ }
346
+
347
+ const links = await RbacGroupMember.find({ groupId: id }).select('userId createdAt').lean();
348
+ const userIds = links.map((l) => l.userId);
349
+
350
+ const users = await User.find({ _id: { $in: userIds } }).select('email name').lean();
351
+ const byId = new Map(users.map((u) => [String(u._id), u]));
352
+
353
+ return res.json({
354
+ members: links.map((l) => {
355
+ const u = byId.get(String(l.userId));
356
+ return {
357
+ id: String(l._id),
358
+ userId: String(l.userId),
359
+ email: u?.email || null,
360
+ name: u?.name || '',
361
+ createdAt: l.createdAt,
362
+ };
363
+ }),
364
+ });
365
+ };
366
+
367
+ exports.addGroupMember = async (req, res) => {
368
+ const { id } = req.params;
369
+ const { userId } = req.body || {};
370
+ if (!isValidObjectId(id) || !isValidObjectId(userId)) {
371
+ return res.status(400).json({ error: 'group id and userId are required' });
372
+ }
373
+
374
+ const group = await RbacGroup.findById(id).select('isGlobal orgId status').lean();
375
+ if (!group) {
376
+ return res.status(404).json({ error: 'Group not found' });
377
+ }
378
+ if (group.status !== 'active') {
379
+ return res.status(400).json({ error: 'Group is not active' });
380
+ }
381
+
382
+ if (!group.isGlobal) {
383
+ const exists = await OrganizationMember.exists({ orgId: group.orgId, userId, status: 'active' });
384
+ if (!exists) {
385
+ return res.status(400).json({ error: 'User is not an active member of the group org' });
386
+ }
387
+ }
388
+
389
+ const actor = getBasicAuthActor(req);
390
+ const member = await RbacGroupMember.create({ groupId: id, userId });
391
+
392
+ await createAuditEvent({
393
+ ...actor,
394
+ action: 'admin.rbac.group_member.add',
395
+ entityType: 'RbacGroup',
396
+ entityId: String(id),
397
+ before: null,
398
+ after: { groupId: String(id), userId: String(userId) },
399
+ meta: null,
400
+ });
401
+
402
+ return res.status(201).json({ member: { ...member.toObject(), id: String(member._id) } });
403
+ };
404
+
405
+ exports.removeGroupMember = async (req, res) => {
406
+ const { id, memberId } = req.params;
407
+ if (!isValidObjectId(id) || !isValidObjectId(memberId)) {
408
+ return res.status(400).json({ error: 'Invalid ids' });
409
+ }
410
+
411
+ const actor = getBasicAuthActor(req);
412
+ const link = await RbacGroupMember.findOne({ _id: memberId, groupId: id });
413
+ if (!link) {
414
+ return res.status(404).json({ error: 'Member not found' });
415
+ }
416
+
417
+ const before = link.toObject();
418
+ await link.deleteOne();
419
+
420
+ await createAuditEvent({
421
+ ...actor,
422
+ action: 'admin.rbac.group_member.remove',
423
+ entityType: 'RbacGroup',
424
+ entityId: String(id),
425
+ before,
426
+ after: null,
427
+ meta: null,
428
+ });
429
+
430
+ return res.json({ success: true });
431
+ };
432
+
433
+ exports.addGroupMembersBulk = async (req, res) => {
434
+ const { id } = req.params;
435
+ const { userIds } = req.body || {};
436
+
437
+ if (!isValidObjectId(id)) {
438
+ return res.status(400).json({ error: 'Invalid group id' });
439
+ }
440
+
441
+ const ids = Array.isArray(userIds) ? userIds.map((v) => String(v)).filter(Boolean) : [];
442
+ const uniqueUserIds = Array.from(new Set(ids));
443
+
444
+ if (uniqueUserIds.length === 0) {
445
+ return res.status(400).json({ error: 'userIds is required' });
446
+ }
447
+
448
+ const group = await RbacGroup.findById(id).select('isGlobal orgId status').lean();
449
+ if (!group) {
450
+ return res.status(404).json({ error: 'Group not found' });
451
+ }
452
+ if (group.status !== 'active') {
453
+ return res.status(400).json({ error: 'Group is not active' });
454
+ }
455
+
456
+ const validUserIds = uniqueUserIds.filter((uid) => isValidObjectId(uid));
457
+ if (validUserIds.length !== uniqueUserIds.length) {
458
+ return res.status(400).json({ error: 'Invalid userIds' });
459
+ }
460
+
461
+ if (!group.isGlobal) {
462
+ const rows = await OrganizationMember.find({ orgId: group.orgId, status: 'active', userId: { $in: validUserIds } })
463
+ .select('userId')
464
+ .lean();
465
+ const allowed = new Set(rows.map((r) => String(r.userId)));
466
+ const deniedUserIds = validUserIds.filter((uid) => !allowed.has(String(uid)));
467
+ if (deniedUserIds.length) {
468
+ return res.status(400).json({ error: 'Some users are not active members of the group org', deniedUserIds });
469
+ }
470
+ }
471
+
472
+ const actor = getBasicAuthActor(req);
473
+
474
+ const inserts = validUserIds.map((uid) => ({ groupId: id, userId: uid }));
475
+ let insertedCount = 0;
476
+ try {
477
+ const created = await RbacGroupMember.insertMany(inserts, { ordered: false });
478
+ insertedCount = Array.isArray(created) ? created.length : 0;
479
+ } catch (e) {
480
+ // Swallow duplicate-key errors; bubble up anything else
481
+ if (!(e && Array.isArray(e.writeErrors))) throw e;
482
+ insertedCount = Array.isArray(e.insertedDocs) ? e.insertedDocs.length : 0;
483
+ }
484
+
485
+ await createAuditEvent({
486
+ ...actor,
487
+ action: 'admin.rbac.group_member.bulk_add',
488
+ entityType: 'RbacGroup',
489
+ entityId: String(id),
490
+ before: null,
491
+ after: { groupId: String(id), userIds: validUserIds },
492
+ meta: { insertedCount },
493
+ });
494
+
495
+ return res.status(201).json({ success: true, insertedCount });
496
+ };
497
+
498
+ exports.removeGroupMembersBulk = async (req, res) => {
499
+ const { id } = req.params;
500
+ const { memberIds } = req.body || {};
501
+
502
+ if (!isValidObjectId(id)) {
503
+ return res.status(400).json({ error: 'Invalid group id' });
504
+ }
505
+
506
+ const ids = Array.isArray(memberIds) ? memberIds.map((v) => String(v)).filter(Boolean) : [];
507
+ const uniqueMemberIds = Array.from(new Set(ids));
508
+ if (uniqueMemberIds.length === 0) {
509
+ return res.status(400).json({ error: 'memberIds is required' });
510
+ }
511
+
512
+ const validMemberIds = uniqueMemberIds.filter((mid) => isValidObjectId(mid));
513
+ if (validMemberIds.length !== uniqueMemberIds.length) {
514
+ return res.status(400).json({ error: 'Invalid memberIds' });
515
+ }
516
+
517
+ const actor = getBasicAuthActor(req);
518
+ const result = await RbacGroupMember.deleteMany({ groupId: id, _id: { $in: validMemberIds } });
519
+
520
+ await createAuditEvent({
521
+ ...actor,
522
+ action: 'admin.rbac.group_member.bulk_remove',
523
+ entityType: 'RbacGroup',
524
+ entityId: String(id),
525
+ before: null,
526
+ after: { groupId: String(id), memberIds: validMemberIds },
527
+ meta: { deletedCount: result?.deletedCount ?? null },
528
+ });
529
+
530
+ return res.json({ success: true, deletedCount: result?.deletedCount ?? 0 });
531
+ };
532
+
533
+ exports.listGroupRoles = async (req, res) => {
534
+ const { id } = req.params;
535
+ if (!isValidObjectId(id)) {
536
+ return res.status(400).json({ error: 'Invalid group id' });
537
+ }
538
+
539
+ const links = await RbacGroupRole.find({ groupId: id }).select('roleId createdAt').lean();
540
+ const roleIds = links.map((l) => l.roleId);
541
+
542
+ const roles = await RbacRole.find({ _id: { $in: roleIds } })
543
+ .select('key name status isGlobal orgId')
544
+ .lean();
545
+ const byId = new Map(roles.map((r) => [String(r._id), r]));
546
+
547
+ return res.json({
548
+ roles: links.map((l) => {
549
+ const r = byId.get(String(l.roleId));
550
+ return {
551
+ id: String(l._id),
552
+ roleId: String(l.roleId),
553
+ key: r?.key || null,
554
+ name: r?.name || null,
555
+ status: r?.status || null,
556
+ isGlobal: r?.isGlobal ?? null,
557
+ orgId: r?.orgId ? String(r.orgId) : null,
558
+ createdAt: l.createdAt,
559
+ };
560
+ }),
561
+ });
562
+ };
563
+
564
+ exports.addGroupRole = async (req, res) => {
565
+ const { id } = req.params;
566
+ const { roleId } = req.body || {};
567
+ if (!isValidObjectId(id) || !isValidObjectId(roleId)) {
568
+ return res.status(400).json({ error: 'group id and roleId are required' });
569
+ }
570
+
571
+ const group = await RbacGroup.findById(id).select('isGlobal orgId status').lean();
572
+ if (!group) {
573
+ return res.status(404).json({ error: 'Group not found' });
574
+ }
575
+ if (group.status !== 'active') {
576
+ return res.status(400).json({ error: 'Group is not active' });
577
+ }
578
+
579
+ const role = await RbacRole.findById(roleId).select('isGlobal orgId status').lean();
580
+ if (!role) {
581
+ return res.status(404).json({ error: 'Role not found' });
582
+ }
583
+ if (role.status !== 'active') {
584
+ return res.status(400).json({ error: 'Role is not active' });
585
+ }
586
+
587
+ // Scoping rules:
588
+ // - Global group cannot have org-scoped roles
589
+ // - Org-scoped group can have global roles and org-scoped roles of the same org
590
+ if (group.isGlobal && !role.isGlobal) {
591
+ return res.status(400).json({ error: 'Global groups cannot include org-scoped roles' });
592
+ }
593
+
594
+ if (!group.isGlobal && !role.isGlobal) {
595
+ if (!group.orgId || !role.orgId || String(group.orgId) !== String(role.orgId)) {
596
+ return res.status(400).json({ error: 'Org-scoped roles must match the group orgId' });
597
+ }
598
+ }
599
+
600
+ const actor = getBasicAuthActor(req);
601
+ const link = await RbacGroupRole.create({ groupId: id, roleId });
602
+
603
+ await createAuditEvent({
604
+ ...actor,
605
+ action: 'admin.rbac.group_role.add',
606
+ entityType: 'RbacGroup',
607
+ entityId: String(id),
608
+ before: null,
609
+ after: { groupId: String(id), roleId: String(roleId) },
610
+ meta: null,
611
+ });
612
+
613
+ return res.status(201).json({ groupRole: { ...link.toObject(), id: String(link._id) } });
614
+ };
615
+
616
+ exports.removeGroupRole = async (req, res) => {
617
+ const { id, groupRoleId } = req.params;
618
+ if (!isValidObjectId(id) || !isValidObjectId(groupRoleId)) {
619
+ return res.status(400).json({ error: 'Invalid ids' });
620
+ }
621
+
622
+ const actor = getBasicAuthActor(req);
623
+ const link = await RbacGroupRole.findOne({ _id: groupRoleId, groupId: id });
624
+ if (!link) {
625
+ return res.status(404).json({ error: 'Group role link not found' });
626
+ }
627
+
628
+ const before = link.toObject();
629
+ await link.deleteOne();
630
+
631
+ await createAuditEvent({
632
+ ...actor,
633
+ action: 'admin.rbac.group_role.remove',
634
+ entityType: 'RbacGroup',
635
+ entityId: String(id),
636
+ before,
637
+ after: null,
638
+ meta: null,
639
+ });
640
+
641
+ return res.json({ success: true });
642
+ };
643
+
644
+ exports.listGrants = async (req, res) => {
645
+ const { subjectType, subjectId, scopeType, scopeId, right } = req.query;
646
+ const q = {};
647
+ if (subjectType) q.subjectType = String(subjectType);
648
+ if (subjectId && isValidObjectId(subjectId)) q.subjectId = subjectId;
649
+ if (scopeType) q.scopeType = String(scopeType);
650
+ if (scopeId && isValidObjectId(scopeId)) q.scopeId = scopeId;
651
+ if (right) q.right = String(right);
652
+
653
+ const grants = await RbacGrant.find(q).sort({ createdAt: -1 }).lean();
654
+ return res.json({
655
+ grants: grants.map((g) => ({
656
+ ...g,
657
+ id: String(g._id),
658
+ subjectId: String(g.subjectId),
659
+ scopeId: g.scopeId ? String(g.scopeId) : null,
660
+ })),
661
+ });
662
+ };
663
+
664
+ exports.createGrant = async (req, res) => {
665
+ const { subjectType, subjectId, scopeType, scopeId, right, effect } = req.body || {};
666
+ if (!subjectType || !isValidObjectId(subjectId) || !scopeType || !right) {
667
+ return res.status(400).json({ error: 'subjectType, subjectId, scopeType, right are required' });
668
+ }
669
+
670
+ if (scopeType === 'org' && !isValidObjectId(scopeId)) {
671
+ return res.status(400).json({ error: 'scopeId is required when scopeType=org' });
672
+ }
673
+
674
+ const actor = getBasicAuthActor(req);
675
+
676
+ const grant = await RbacGrant.create({
677
+ subjectType: String(subjectType),
678
+ subjectId,
679
+ scopeType: String(scopeType),
680
+ scopeId: scopeType === 'org' ? scopeId : null,
681
+ right: String(right).trim(),
682
+ effect: effect === 'deny' ? 'deny' : 'allow',
683
+ createdByActorType: actor.actorType,
684
+ createdByActorId: actor.actorId,
685
+ });
686
+
687
+ await createAuditEvent({
688
+ ...actor,
689
+ action: 'admin.rbac.grant.create',
690
+ entityType: 'RbacGrant',
691
+ entityId: String(grant._id),
692
+ before: null,
693
+ after: grant.toObject(),
694
+ meta: null,
695
+ });
696
+
697
+ return res.status(201).json({ grant: { ...grant.toObject(), id: String(grant._id) } });
698
+ };
699
+
700
+ exports.deleteGrant = async (req, res) => {
701
+ const { id } = req.params;
702
+ if (!isValidObjectId(id)) {
703
+ return res.status(400).json({ error: 'Invalid grant id' });
704
+ }
705
+
706
+ const actor = getBasicAuthActor(req);
707
+ const grant = await RbacGrant.findById(id);
708
+ if (!grant) {
709
+ return res.status(404).json({ error: 'Grant not found' });
710
+ }
711
+
712
+ const before = grant.toObject();
713
+ await grant.deleteOne();
714
+
715
+ await createAuditEvent({
716
+ ...actor,
717
+ action: 'admin.rbac.grant.delete',
718
+ entityType: 'RbacGrant',
719
+ entityId: String(id),
720
+ before,
721
+ after: null,
722
+ meta: null,
723
+ });
724
+
725
+ return res.json({ success: true });
726
+ };
727
+
728
+ exports.listUserRoles = async (req, res) => {
729
+ const { userId } = req.params;
730
+ if (!isValidObjectId(userId)) {
731
+ return res.status(400).json({ error: 'Invalid userId' });
732
+ }
733
+
734
+ const rows = await RbacUserRole.find({ userId }).select('roleId createdAt').lean();
735
+ const roleIds = rows.map((r) => r.roleId);
736
+ const roles = await RbacRole.find({ _id: { $in: roleIds } }).select('key name status').lean();
737
+ const byId = new Map(roles.map((r) => [String(r._id), r]));
738
+
739
+ return res.json({
740
+ roles: rows.map((r) => {
741
+ const role = byId.get(String(r.roleId));
742
+ return {
743
+ id: String(r._id),
744
+ roleId: String(r.roleId),
745
+ key: role?.key || null,
746
+ name: role?.name || null,
747
+ status: role?.status || null,
748
+ createdAt: r.createdAt,
749
+ };
750
+ }),
751
+ });
752
+ };
753
+
754
+ exports.addUserRole = async (req, res) => {
755
+ const { userId } = req.params;
756
+ const { roleId } = req.body || {};
757
+ if (!isValidObjectId(userId) || !isValidObjectId(roleId)) {
758
+ return res.status(400).json({ error: 'userId and roleId are required' });
759
+ }
760
+
761
+ const actor = getBasicAuthActor(req);
762
+ const link = await RbacUserRole.create({ userId, roleId });
763
+
764
+ await createAuditEvent({
765
+ ...actor,
766
+ action: 'admin.rbac.user_role.add',
767
+ entityType: 'User',
768
+ entityId: String(userId),
769
+ before: null,
770
+ after: { roleId: String(roleId) },
771
+ meta: null,
772
+ });
773
+
774
+ return res.status(201).json({ userRole: { ...link.toObject(), id: String(link._id) } });
775
+ };
776
+
777
+ exports.removeUserRole = async (req, res) => {
778
+ const { userId, userRoleId } = req.params;
779
+ if (!isValidObjectId(userId) || !isValidObjectId(userRoleId)) {
780
+ return res.status(400).json({ error: 'Invalid ids' });
781
+ }
782
+
783
+ const actor = getBasicAuthActor(req);
784
+ const link = await RbacUserRole.findOne({ _id: userRoleId, userId });
785
+ if (!link) {
786
+ return res.status(404).json({ error: 'User role link not found' });
787
+ }
788
+
789
+ const before = link.toObject();
790
+ await link.deleteOne();
791
+
792
+ await createAuditEvent({
793
+ ...actor,
794
+ action: 'admin.rbac.user_role.remove',
795
+ entityType: 'User',
796
+ entityId: String(userId),
797
+ before,
798
+ after: null,
799
+ meta: null,
800
+ });
801
+
802
+ return res.json({ success: true });
803
+ };