@oondemand/oon-core-back 0.3.74 → 0.3.75
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/package.json +1 -1
- package/src/api/access.routes.js +353 -0
- package/src/api/auth.routes.js +39 -34
- package/src/boot/createApp.js +2 -2
- package/src/core/define.js +5 -1
- package/src/core/factories/modelRouter.js +18 -15
- package/src/core/middlewares/auth.js +35 -2
- package/src/core/middlewares/permissions.js +37 -0
- package/src/core/middlewares/rbac.js +38 -10
- package/src/core/rbac/identityProvider.js +66 -0
- package/src/core/rbac/policy.js +168 -0
- package/src/core/rbac/storage.js +387 -0
- package/src/index.js +8 -0
- package/src/manifest/appManifest.js +26 -104
- package/src/manifest/rbacManifest.js +89 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const mongoose = require("mongoose");
|
|
5
|
+
const ctx = require("../context");
|
|
6
|
+
const GenericError = require("../errors/GenericError");
|
|
7
|
+
const { encrypt, decrypt } = require("../utils/instanceActivation");
|
|
8
|
+
const {
|
|
9
|
+
policy,
|
|
10
|
+
roleByCode,
|
|
11
|
+
rolePermissions,
|
|
12
|
+
legacyPermissions,
|
|
13
|
+
matchingRoleForLegacyUser,
|
|
14
|
+
} = require("./policy");
|
|
15
|
+
|
|
16
|
+
const ACCESS_STATUSES = ["invited", "active", "disabled", "revoked"];
|
|
17
|
+
const INVITATION_STATUSES = ["pending", "accepted", "revoked", "expired"];
|
|
18
|
+
|
|
19
|
+
function models() {
|
|
20
|
+
const accessSchema = new mongoose.Schema({
|
|
21
|
+
appCode: { type: String, required: true, index: true },
|
|
22
|
+
identityId: { type: String, index: true },
|
|
23
|
+
email: { type: String, required: true, lowercase: true, trim: true },
|
|
24
|
+
name: String,
|
|
25
|
+
roleCode: { type: String, required: true },
|
|
26
|
+
status: { type: String, enum: ACCESS_STATUSES, default: "invited", index: true },
|
|
27
|
+
origin: { type: String, default: "rbac" },
|
|
28
|
+
grantedBy: String,
|
|
29
|
+
mustChangePassword: { type: Boolean, default: false },
|
|
30
|
+
invitedAt: Date,
|
|
31
|
+
activatedAt: Date,
|
|
32
|
+
disabledAt: Date,
|
|
33
|
+
revokedAt: Date,
|
|
34
|
+
lastAccessAt: Date,
|
|
35
|
+
}, { timestamps: true, collection: "oon_core_user_access" });
|
|
36
|
+
accessSchema.index({ appCode: 1, email: 1 }, { unique: true });
|
|
37
|
+
accessSchema.index(
|
|
38
|
+
{ appCode: 1, identityId: 1 },
|
|
39
|
+
{ unique: true, partialFilterExpression: { identityId: { $type: "string" } } },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const invitationSchema = new mongoose.Schema({
|
|
43
|
+
appCode: { type: String, required: true, index: true },
|
|
44
|
+
accessId: { type: mongoose.Schema.Types.ObjectId, ref: "OonCoreUserAccess", required: true },
|
|
45
|
+
email: { type: String, required: true, lowercase: true, trim: true },
|
|
46
|
+
name: String,
|
|
47
|
+
roleCode: { type: String, required: true },
|
|
48
|
+
tokenHash: { type: String, required: true, unique: true, select: false },
|
|
49
|
+
tokenFingerprint: { type: String, required: true },
|
|
50
|
+
tokenEncrypted: { type: String, select: false },
|
|
51
|
+
tokenIv: { type: String, select: false },
|
|
52
|
+
tokenAuthTag: { type: String, select: false },
|
|
53
|
+
status: { type: String, enum: INVITATION_STATUSES, default: "pending", index: true },
|
|
54
|
+
expiresAt: { type: Date, required: true, index: true },
|
|
55
|
+
createdBy: String,
|
|
56
|
+
acceptedAt: Date,
|
|
57
|
+
revokedAt: Date,
|
|
58
|
+
}, { timestamps: true, collection: "oon_core_invitations" });
|
|
59
|
+
invitationSchema.index({ appCode: 1, email: 1, status: 1 });
|
|
60
|
+
|
|
61
|
+
const auditSchema = new mongoose.Schema({
|
|
62
|
+
appCode: { type: String, required: true, index: true },
|
|
63
|
+
event: { type: String, required: true, index: true },
|
|
64
|
+
actor: String,
|
|
65
|
+
target: String,
|
|
66
|
+
before: mongoose.Schema.Types.Mixed,
|
|
67
|
+
after: mongoose.Schema.Types.Mixed,
|
|
68
|
+
context: mongoose.Schema.Types.Mixed,
|
|
69
|
+
at: { type: Date, default: Date.now, index: true },
|
|
70
|
+
}, { timestamps: false, collection: "oon_core_access_audit" });
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
UserAccess: mongoose.models.OonCoreUserAccess || mongoose.model("OonCoreUserAccess", accessSchema),
|
|
74
|
+
Invitation: mongoose.models.OonCoreInvitation || mongoose.model("OonCoreInvitation", invitationSchema),
|
|
75
|
+
Audit: mongoose.models.OonCoreAccessAudit || mongoose.model("OonCoreAccessAudit", auditSchema),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function appCode() {
|
|
80
|
+
return String(ctx.config.appCode || ctx.config.slug || "central").trim().toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeEmail(value) {
|
|
84
|
+
return String(value || "").trim().toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function identityKey(user) {
|
|
88
|
+
return String(user?.id || user?._id || user?.usuarioId || "").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function actorKey(user) {
|
|
92
|
+
return identityKey(user) || normalizeEmail(user?.email) || "system";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sanitizeSnapshot(value) {
|
|
96
|
+
if (!value) return value;
|
|
97
|
+
const plain = typeof value.toObject === "function" ? value.toObject() : { ...value };
|
|
98
|
+
delete plain.tokenHash;
|
|
99
|
+
delete plain.tokenEncrypted;
|
|
100
|
+
delete plain.tokenIv;
|
|
101
|
+
delete plain.tokenAuthTag;
|
|
102
|
+
delete plain.senha;
|
|
103
|
+
delete plain.password;
|
|
104
|
+
return plain;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function audit(event, { actor, target, before, after, context } = {}) {
|
|
108
|
+
if (mongoose.connection.readyState !== 1) return null;
|
|
109
|
+
const { Audit } = models();
|
|
110
|
+
return Audit.create({
|
|
111
|
+
appCode: appCode(),
|
|
112
|
+
event,
|
|
113
|
+
actor: typeof actor === "string" ? actor : actorKey(actor),
|
|
114
|
+
target: typeof target === "string" ? target : target && (identityKey(target) || normalizeEmail(target.email)),
|
|
115
|
+
before: sanitizeSnapshot(before),
|
|
116
|
+
after: sanitizeSnapshot(after),
|
|
117
|
+
context,
|
|
118
|
+
at: new Date(),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function accessError(message, code = "RBAC_ACCESS_DENIED", statusCode = 403) {
|
|
123
|
+
return new GenericError(message, { statusCode, code });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function findAccessForUser(user) {
|
|
127
|
+
if (mongoose.connection.readyState !== 1) return null;
|
|
128
|
+
const { UserAccess } = models();
|
|
129
|
+
const conditions = [];
|
|
130
|
+
const id = identityKey(user);
|
|
131
|
+
const email = normalizeEmail(user?.email);
|
|
132
|
+
if (id) conditions.push({ identityId: id });
|
|
133
|
+
if (email) conditions.push({ email });
|
|
134
|
+
if (!conditions.length) return null;
|
|
135
|
+
return UserAccess.findOne({ appCode: appCode(), $or: conditions });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function migrateLegacyAccess(user) {
|
|
139
|
+
const currentPolicy = policy();
|
|
140
|
+
if (!currentPolicy.enabled || mongoose.connection.readyState !== 1) return null;
|
|
141
|
+
const role = matchingRoleForLegacyUser(user);
|
|
142
|
+
if (!role) return null;
|
|
143
|
+
|
|
144
|
+
const { UserAccess } = models();
|
|
145
|
+
const email = normalizeEmail(user?.email);
|
|
146
|
+
if (!email) return null;
|
|
147
|
+
const identityId = identityKey(user) || undefined;
|
|
148
|
+
const existing = await UserAccess.findOne({ appCode: appCode(), email });
|
|
149
|
+
if (existing) return existing;
|
|
150
|
+
|
|
151
|
+
const created = await UserAccess.create({
|
|
152
|
+
appCode: appCode(),
|
|
153
|
+
identityId,
|
|
154
|
+
email,
|
|
155
|
+
name: user?.nome || user?.name,
|
|
156
|
+
roleCode: role.code,
|
|
157
|
+
status: "active",
|
|
158
|
+
origin: "legacy-migration",
|
|
159
|
+
grantedBy: "system:migration",
|
|
160
|
+
mustChangePassword: false,
|
|
161
|
+
activatedAt: new Date(),
|
|
162
|
+
lastAccessAt: new Date(),
|
|
163
|
+
});
|
|
164
|
+
await audit("user.migrated", { actor: "system:migration", target: created, after: created });
|
|
165
|
+
return created;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveUserAccess(user, { touch = true } = {}) {
|
|
169
|
+
const currentPolicy = policy();
|
|
170
|
+
if (!currentPolicy.enabled) {
|
|
171
|
+
return {
|
|
172
|
+
mode: "legacy",
|
|
173
|
+
status: "active",
|
|
174
|
+
roleCode: user?.perfil || user?.tipo || "user",
|
|
175
|
+
permissions: legacyPermissions(user),
|
|
176
|
+
mustChangePassword: Boolean(user?.mustChangePassword || user?.firstAccessPending),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let access = await findAccessForUser(user);
|
|
181
|
+
if (!access) access = await migrateLegacyAccess(user);
|
|
182
|
+
if (!access) {
|
|
183
|
+
throw accessError("Usuário autenticado sem perfil local nesta Central.", "RBAC_LOCAL_ACCESS_REQUIRED");
|
|
184
|
+
}
|
|
185
|
+
if (["disabled", "revoked"].includes(access.status)) {
|
|
186
|
+
throw accessError("Acesso do usuário está desabilitado nesta Central.", "RBAC_USER_DISABLED");
|
|
187
|
+
}
|
|
188
|
+
if (access.status === "invited") {
|
|
189
|
+
throw accessError("Convite ainda não foi concluído.", "RBAC_INVITATION_PENDING");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const role = roleByCode(access.roleCode);
|
|
193
|
+
if (!role) {
|
|
194
|
+
throw accessError("O perfil associado ao usuário não existe mais na política da Central.", "RBAC_ROLE_NOT_FOUND");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const id = identityKey(user);
|
|
198
|
+
let changed = false;
|
|
199
|
+
if (id && access.identityId !== id) {
|
|
200
|
+
access.identityId = id;
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
if (user?.nome && access.name !== user.nome) {
|
|
204
|
+
access.name = user.nome;
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
if (touch && (!access.lastAccessAt || Date.now() - access.lastAccessAt.getTime() > 5 * 60 * 1000)) {
|
|
208
|
+
access.lastAccessAt = new Date();
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
if (changed) await access.save();
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
mode: "rbac",
|
|
215
|
+
access,
|
|
216
|
+
status: access.status,
|
|
217
|
+
roleCode: access.roleCode,
|
|
218
|
+
role,
|
|
219
|
+
permissions: rolePermissions(role),
|
|
220
|
+
mustChangePassword: Boolean(access.mustChangePassword),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function hashToken(token) {
|
|
225
|
+
return crypto.createHash("sha256").update(String(token)).digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function tokenFingerprint(token) {
|
|
229
|
+
return hashToken(token).slice(0, 12);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function protectInvitationToken(token) {
|
|
233
|
+
const protectedToken = encrypt(String(token));
|
|
234
|
+
return {
|
|
235
|
+
tokenEncrypted: protectedToken.credentialEncrypted,
|
|
236
|
+
tokenIv: protectedToken.credentialIv,
|
|
237
|
+
tokenAuthTag: protectedToken.credentialAuthTag,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function recoverInvitationToken(invitation) {
|
|
242
|
+
if (!invitation?.tokenEncrypted || !invitation?.tokenIv || !invitation?.tokenAuthTag) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return decrypt({
|
|
246
|
+
credentialEncrypted: invitation.tokenEncrypted,
|
|
247
|
+
credentialIv: invitation.tokenIv,
|
|
248
|
+
credentialAuthTag: invitation.tokenAuthTag,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function invitationExpiry() {
|
|
253
|
+
return new Date(Date.now() + policy().invitationTtlHours * 60 * 60 * 1000);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function revokePendingInvitations(email, actor) {
|
|
257
|
+
const { Invitation } = models();
|
|
258
|
+
const pending = await Invitation.find({ appCode: appCode(), email: normalizeEmail(email), status: "pending" });
|
|
259
|
+
for (const invitation of pending) {
|
|
260
|
+
invitation.status = "revoked";
|
|
261
|
+
invitation.revokedAt = new Date();
|
|
262
|
+
await invitation.save();
|
|
263
|
+
await audit("invitation.revoked", { actor, target: invitation.email, before: invitation });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function createInvitation({ email, name, roleCode, actor }) {
|
|
268
|
+
if (mongoose.connection.readyState !== 1) {
|
|
269
|
+
throw new GenericError("Banco de dados indisponível para criar convites.", { statusCode: 503 });
|
|
270
|
+
}
|
|
271
|
+
const role = roleByCode(roleCode);
|
|
272
|
+
if (!role) throw new GenericError("Perfil inválido.", { statusCode: 422, code: "RBAC_ROLE_NOT_FOUND" });
|
|
273
|
+
const normalizedEmail = normalizeEmail(email);
|
|
274
|
+
if (!/^\S+@\S+\.\S+$/.test(normalizedEmail)) {
|
|
275
|
+
throw new GenericError("Informe um e-mail válido.", { statusCode: 422 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { UserAccess, Invitation } = models();
|
|
279
|
+
await revokePendingInvitations(normalizedEmail, actor);
|
|
280
|
+
let access = await UserAccess.findOne({ appCode: appCode(), email: normalizedEmail });
|
|
281
|
+
const before = sanitizeSnapshot(access);
|
|
282
|
+
if (!access) {
|
|
283
|
+
access = new UserAccess({ appCode: appCode(), email: normalizedEmail });
|
|
284
|
+
}
|
|
285
|
+
access.name = name || access.name;
|
|
286
|
+
access.roleCode = role.code;
|
|
287
|
+
access.status = "invited";
|
|
288
|
+
access.origin = "invitation";
|
|
289
|
+
access.grantedBy = actorKey(actor);
|
|
290
|
+
access.mustChangePassword = true;
|
|
291
|
+
access.invitedAt = new Date();
|
|
292
|
+
access.disabledAt = undefined;
|
|
293
|
+
access.revokedAt = undefined;
|
|
294
|
+
await access.save();
|
|
295
|
+
|
|
296
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
297
|
+
const invitation = await Invitation.create({
|
|
298
|
+
appCode: appCode(),
|
|
299
|
+
accessId: access._id,
|
|
300
|
+
email: normalizedEmail,
|
|
301
|
+
name: access.name,
|
|
302
|
+
roleCode: role.code,
|
|
303
|
+
tokenHash: hashToken(token),
|
|
304
|
+
tokenFingerprint: tokenFingerprint(token),
|
|
305
|
+
...protectInvitationToken(token),
|
|
306
|
+
expiresAt: invitationExpiry(),
|
|
307
|
+
status: "pending",
|
|
308
|
+
createdBy: actorKey(actor),
|
|
309
|
+
});
|
|
310
|
+
await audit("invitation.created", { actor, target: normalizedEmail, before, after: access, context: { invitationId: String(invitation._id) } });
|
|
311
|
+
return { access, invitation, token };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function findInvitationByToken(token) {
|
|
315
|
+
if (mongoose.connection.readyState !== 1) return null;
|
|
316
|
+
const { Invitation } = models();
|
|
317
|
+
const invitation = await Invitation.findOne({ appCode: appCode(), tokenHash: hashToken(token) }).select("+tokenHash");
|
|
318
|
+
if (!invitation) return null;
|
|
319
|
+
if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
|
|
320
|
+
invitation.status = "expired";
|
|
321
|
+
await invitation.save();
|
|
322
|
+
await audit("invitation.expired", { actor: "system", target: invitation.email, context: { invitationId: String(invitation._id) } });
|
|
323
|
+
}
|
|
324
|
+
return invitation;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function recoverPendingInvitation(id) {
|
|
328
|
+
if (mongoose.connection.readyState !== 1) return null;
|
|
329
|
+
const { Invitation } = models();
|
|
330
|
+
const invitation = await Invitation.findOne({ _id: id, appCode: appCode() })
|
|
331
|
+
.select("+tokenEncrypted +tokenIv +tokenAuthTag");
|
|
332
|
+
if (!invitation) return null;
|
|
333
|
+
if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
|
|
334
|
+
invitation.status = "expired";
|
|
335
|
+
await invitation.save();
|
|
336
|
+
return { invitation, token: null };
|
|
337
|
+
}
|
|
338
|
+
if (invitation.status !== "pending") return { invitation, token: null };
|
|
339
|
+
return { invitation, token: recoverInvitationToken(invitation) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function activeAdminCount() {
|
|
343
|
+
const adminCodes = policy().roles
|
|
344
|
+
.filter((role) => role.admin === true || role.permissions?.includes("*"))
|
|
345
|
+
.map((role) => role.code);
|
|
346
|
+
if (!adminCodes.length || mongoose.connection.readyState !== 1) return 0;
|
|
347
|
+
const { UserAccess } = models();
|
|
348
|
+
return UserAccess.countDocuments({ appCode: appCode(), status: "active", roleCode: { $in: adminCodes } });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function assertNotLastAdmin(access, nextRoleCode) {
|
|
352
|
+
const currentRole = roleByCode(access.roleCode);
|
|
353
|
+
const nextRole = nextRoleCode ? roleByCode(nextRoleCode) : null;
|
|
354
|
+
const currentAdmin = Boolean(currentRole?.admin || currentRole?.permissions?.includes("*"));
|
|
355
|
+
const nextAdmin = Boolean(nextRole?.admin || nextRole?.permissions?.includes("*"));
|
|
356
|
+
if (currentAdmin && !nextAdmin && (await activeAdminCount()) <= 1) {
|
|
357
|
+
throw new GenericError("Não é possível remover ou desabilitar o último administrador da Central.", {
|
|
358
|
+
statusCode: 409,
|
|
359
|
+
code: "RBAC_LAST_ADMIN_PROTECTED",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
module.exports = {
|
|
365
|
+
ACCESS_STATUSES,
|
|
366
|
+
INVITATION_STATUSES,
|
|
367
|
+
models,
|
|
368
|
+
appCode,
|
|
369
|
+
normalizeEmail,
|
|
370
|
+
identityKey,
|
|
371
|
+
actorKey,
|
|
372
|
+
sanitizeSnapshot,
|
|
373
|
+
audit,
|
|
374
|
+
findAccessForUser,
|
|
375
|
+
migrateLegacyAccess,
|
|
376
|
+
resolveUserAccess,
|
|
377
|
+
hashToken,
|
|
378
|
+
tokenFingerprint,
|
|
379
|
+
protectInvitationToken,
|
|
380
|
+
recoverInvitationToken,
|
|
381
|
+
createInvitation,
|
|
382
|
+
revokePendingInvitations,
|
|
383
|
+
findInvitationByToken,
|
|
384
|
+
recoverPendingInvitation,
|
|
385
|
+
activeAdminCount,
|
|
386
|
+
assertNotLastAdmin,
|
|
387
|
+
};
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,8 @@ const define = require("./core/define");
|
|
|
7
7
|
const fields = require("./core/fields");
|
|
8
8
|
const registry = require("./core/registry");
|
|
9
9
|
const GenericError = require("./core/errors/GenericError");
|
|
10
|
+
const { requirePermission } = require("./core/middlewares/permissions");
|
|
11
|
+
const rbacPolicy = require("./core/rbac/policy");
|
|
10
12
|
const omieModule = require("./modules/omie");
|
|
11
13
|
const integrations = require("./modules/integrations");
|
|
12
14
|
const appManifest = require("./manifest/appManifest");
|
|
@@ -30,6 +32,12 @@ module.exports = {
|
|
|
30
32
|
defineValidation: define.defineValidation,
|
|
31
33
|
defineTrigger: define.defineTrigger,
|
|
32
34
|
|
|
35
|
+
requirePermission,
|
|
36
|
+
CORE_PERMISSIONS: rbacPolicy.CORE_PERMISSIONS,
|
|
37
|
+
rbacPolicy: rbacPolicy.policy,
|
|
38
|
+
roleByCode: rbacPolicy.roleByCode,
|
|
39
|
+
permissionGranted: rbacPolicy.granted,
|
|
40
|
+
|
|
33
41
|
APP_MANIFEST_FILENAME: appManifest.APP_MANIFEST_FILENAME,
|
|
34
42
|
APP_MANIFEST_SCHEMA_VERSION: appManifest.APP_MANIFEST_SCHEMA_VERSION,
|
|
35
43
|
SUPPORTED_APP_KINDS: appManifest.SUPPORTED_APP_KINDS,
|
|
@@ -4,22 +4,13 @@ const fs = require("node:fs");
|
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const ctx = require("../core/context");
|
|
6
6
|
const corePackage = require("../../package.json");
|
|
7
|
+
const { validateRbac } = require("./rbacManifest");
|
|
7
8
|
|
|
8
9
|
const APP_MANIFEST_FILENAME = "central.app.json";
|
|
9
10
|
const APP_MANIFEST_SCHEMA_VERSION = 1;
|
|
10
|
-
const SUPPORTED_APP_KINDS = Object.freeze([
|
|
11
|
-
"root-central",
|
|
12
|
-
"member-central",
|
|
13
|
-
"portal-cockpit",
|
|
14
|
-
]);
|
|
11
|
+
const SUPPORTED_APP_KINDS = Object.freeze(["root-central", "member-central", "portal-cockpit"]);
|
|
15
12
|
const SUPPORTED_MODULES = Object.freeze([
|
|
16
|
-
"collections",
|
|
17
|
-
"documents",
|
|
18
|
-
"pipelines",
|
|
19
|
-
"integrations",
|
|
20
|
-
"omie",
|
|
21
|
-
"assistants",
|
|
22
|
-
"currencies",
|
|
13
|
+
"collections", "documents", "pipelines", "integrations", "omie", "assistants", "currencies",
|
|
23
14
|
]);
|
|
24
15
|
const APP_KIND_ROLE = Object.freeze({
|
|
25
16
|
"root-central": "root",
|
|
@@ -41,22 +32,17 @@ class AppManifestError extends Error {
|
|
|
41
32
|
function isPlainObject(value) {
|
|
42
33
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
43
34
|
}
|
|
44
|
-
|
|
45
35
|
function hasOwn(object, key) {
|
|
46
36
|
return Object.prototype.hasOwnProperty.call(object, key);
|
|
47
37
|
}
|
|
48
|
-
|
|
49
38
|
function addIssue(issues, fieldPath, message) {
|
|
50
39
|
issues.push({ path: fieldPath, message });
|
|
51
40
|
}
|
|
52
|
-
|
|
53
41
|
function parseVersion(value) {
|
|
54
42
|
if (typeof value !== "string") return null;
|
|
55
43
|
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
|
|
56
|
-
|
|
57
|
-
return match.slice(1, 4).map(Number);
|
|
44
|
+
return match ? match.slice(1, 4).map(Number) : null;
|
|
58
45
|
}
|
|
59
|
-
|
|
60
46
|
function compareVersions(left, right) {
|
|
61
47
|
for (let index = 0; index < 3; index += 1) {
|
|
62
48
|
if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1;
|
|
@@ -73,50 +59,28 @@ function validateCompatibility(compatibility, issues, coreVersion) {
|
|
|
73
59
|
addIssue(issues, "compatibility.core", "deve ser um objeto.");
|
|
74
60
|
return;
|
|
75
61
|
}
|
|
76
|
-
|
|
77
62
|
const allowedKeys = new Set(["minVersion", "maxVersionExclusive"]);
|
|
78
63
|
for (const key of Object.keys(compatibility.core)) {
|
|
79
64
|
if (!allowedKeys.has(key)) addIssue(issues, `compatibility.core.${key}`, "propriedade não suportada.");
|
|
80
65
|
}
|
|
81
|
-
|
|
82
66
|
const minVersion = parseVersion(compatibility.core.minVersion);
|
|
83
67
|
const maxVersion = hasOwn(compatibility.core, "maxVersionExclusive")
|
|
84
68
|
? parseVersion(compatibility.core.maxVersionExclusive)
|
|
85
69
|
: null;
|
|
86
|
-
|
|
87
|
-
if (!minVersion) {
|
|
88
|
-
addIssue(issues, "compatibility.core.minVersion", "deve usar versão semântica, por exemplo 0.3.45.");
|
|
89
|
-
}
|
|
70
|
+
if (!minVersion) addIssue(issues, "compatibility.core.minVersion", "deve usar versão semântica, por exemplo 0.3.45.");
|
|
90
71
|
if (hasOwn(compatibility.core, "maxVersionExclusive") && !maxVersion) {
|
|
91
|
-
addIssue(
|
|
92
|
-
issues,
|
|
93
|
-
"compatibility.core.maxVersionExclusive",
|
|
94
|
-
"deve usar versão semântica, por exemplo 0.4.0.",
|
|
95
|
-
);
|
|
72
|
+
addIssue(issues, "compatibility.core.maxVersionExclusive", "deve usar versão semântica, por exemplo 0.4.0.");
|
|
96
73
|
}
|
|
97
74
|
if (minVersion && maxVersion && compareVersions(minVersion, maxVersion) >= 0) {
|
|
98
|
-
addIssue(
|
|
99
|
-
issues,
|
|
100
|
-
"compatibility.core",
|
|
101
|
-
"minVersion deve ser menor que maxVersionExclusive.",
|
|
102
|
-
);
|
|
75
|
+
addIssue(issues, "compatibility.core", "minVersion deve ser menor que maxVersionExclusive.");
|
|
103
76
|
}
|
|
104
|
-
|
|
105
77
|
const installedVersion = parseVersion(coreVersion);
|
|
106
78
|
if (!installedVersion || !minVersion) return;
|
|
107
79
|
if (compareVersions(installedVersion, minVersion) < 0) {
|
|
108
|
-
addIssue(
|
|
109
|
-
issues,
|
|
110
|
-
"compatibility.core.minVersion",
|
|
111
|
-
`requer OonCore ${compatibility.core.minVersion} ou superior; instalado ${coreVersion}.`,
|
|
112
|
-
);
|
|
80
|
+
addIssue(issues, "compatibility.core.minVersion", `requer OonCore ${compatibility.core.minVersion} ou superior; instalado ${coreVersion}.`);
|
|
113
81
|
}
|
|
114
82
|
if (maxVersion && compareVersions(installedVersion, maxVersion) >= 0) {
|
|
115
|
-
addIssue(
|
|
116
|
-
issues,
|
|
117
|
-
"compatibility.core.maxVersionExclusive",
|
|
118
|
-
`requer OonCore anterior a ${compatibility.core.maxVersionExclusive}; instalado ${coreVersion}.`,
|
|
119
|
-
);
|
|
83
|
+
addIssue(issues, "compatibility.core.maxVersionExclusive", `requer OonCore anterior a ${compatibility.core.maxVersionExclusive}; instalado ${coreVersion}.`);
|
|
120
84
|
}
|
|
121
85
|
}
|
|
122
86
|
|
|
@@ -133,10 +97,7 @@ function validateActivation(activation, issues) {
|
|
|
133
97
|
if (hasOwn(activation, "enabled") && typeof activation.enabled !== "boolean") {
|
|
134
98
|
addIssue(issues, "activation.enabled", "deve ser booleano.");
|
|
135
99
|
}
|
|
136
|
-
if (
|
|
137
|
-
hasOwn(activation, "configurationVersion")
|
|
138
|
-
&& (!Number.isInteger(activation.configurationVersion) || activation.configurationVersion < 1)
|
|
139
|
-
) {
|
|
100
|
+
if (hasOwn(activation, "configurationVersion") && (!Number.isInteger(activation.configurationVersion) || activation.configurationVersion < 1)) {
|
|
140
101
|
addIssue(issues, "activation.configurationVersion", "deve ser inteiro maior ou igual a 1.");
|
|
141
102
|
}
|
|
142
103
|
if (hasOwn(activation, "fields") && !Array.isArray(activation.fields)) {
|
|
@@ -147,84 +108,50 @@ function validateActivation(activation, issues) {
|
|
|
147
108
|
function validateAppManifest(input, { coreVersion = corePackage.version } = {}) {
|
|
148
109
|
const issues = [];
|
|
149
110
|
if (!isPlainObject(input)) {
|
|
150
|
-
throw new AppManifestError("central.app.json inválido.", [
|
|
151
|
-
{ path: "$", message: "deve conter um objeto JSON." },
|
|
152
|
-
]);
|
|
111
|
+
throw new AppManifestError("central.app.json inválido.", [{ path: "$", message: "deve conter um objeto JSON." }]);
|
|
153
112
|
}
|
|
154
|
-
|
|
155
113
|
const allowedTopLevel = new Set([
|
|
156
|
-
"$schema",
|
|
157
|
-
"
|
|
158
|
-
"id",
|
|
159
|
-
"name",
|
|
160
|
-
"slug",
|
|
161
|
-
"appKind",
|
|
162
|
-
"modules",
|
|
163
|
-
"capabilities",
|
|
164
|
-
"compatibility",
|
|
165
|
-
"activation",
|
|
114
|
+
"$schema", "schemaVersion", "id", "name", "slug", "appKind", "modules", "capabilities",
|
|
115
|
+
"compatibility", "activation", "rbac",
|
|
166
116
|
]);
|
|
167
117
|
for (const key of Object.keys(input)) {
|
|
168
118
|
if (!allowedTopLevel.has(key)) addIssue(issues, key, "propriedade não suportada.");
|
|
169
119
|
}
|
|
170
|
-
|
|
171
120
|
if (input.schemaVersion !== APP_MANIFEST_SCHEMA_VERSION) {
|
|
172
|
-
addIssue(
|
|
173
|
-
issues,
|
|
174
|
-
"schemaVersion",
|
|
175
|
-
`versão não suportada. Use ${APP_MANIFEST_SCHEMA_VERSION}.`,
|
|
176
|
-
);
|
|
121
|
+
addIssue(issues, "schemaVersion", `versão não suportada. Use ${APP_MANIFEST_SCHEMA_VERSION}.`);
|
|
177
122
|
}
|
|
178
123
|
if (typeof input.id !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
|
|
179
124
|
addIssue(issues, "id", "deve usar letras minúsculas, números, ponto, hífen ou sublinhado.");
|
|
180
125
|
}
|
|
181
|
-
if (typeof input.name !== "string" || !input.name.trim())
|
|
182
|
-
addIssue(issues, "name", "é obrigatório e deve ser texto não vazio.");
|
|
183
|
-
}
|
|
126
|
+
if (typeof input.name !== "string" || !input.name.trim()) addIssue(issues, "name", "é obrigatório e deve ser texto não vazio.");
|
|
184
127
|
if (typeof input.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.slug)) {
|
|
185
128
|
addIssue(issues, "slug", "deve usar slug em letras minúsculas separado por hífen.");
|
|
186
129
|
}
|
|
187
130
|
if (!SUPPORTED_APP_KINDS.includes(input.appKind)) {
|
|
188
131
|
addIssue(issues, "appKind", `deve ser um de: ${SUPPORTED_APP_KINDS.join(", ")}.`);
|
|
189
132
|
}
|
|
190
|
-
|
|
191
133
|
if (!isPlainObject(input.modules)) {
|
|
192
134
|
addIssue(issues, "modules", "deve ser um objeto.");
|
|
193
135
|
} else {
|
|
194
136
|
for (const key of Object.keys(input.modules)) {
|
|
195
|
-
if (!SUPPORTED_MODULES.includes(key)) {
|
|
196
|
-
|
|
197
|
-
} else if (typeof input.modules[key] !== "boolean") {
|
|
198
|
-
addIssue(issues, `modules.${key}`, "deve ser booleano.");
|
|
199
|
-
}
|
|
137
|
+
if (!SUPPORTED_MODULES.includes(key)) addIssue(issues, `modules.${key}`, `módulo não suportado. Use: ${SUPPORTED_MODULES.join(", ")}.`);
|
|
138
|
+
else if (typeof input.modules[key] !== "boolean") addIssue(issues, `modules.${key}`, "deve ser booleano.");
|
|
200
139
|
}
|
|
201
140
|
}
|
|
202
|
-
|
|
203
141
|
if (!Array.isArray(input.capabilities)) {
|
|
204
142
|
addIssue(issues, "capabilities", "deve ser uma lista.");
|
|
205
143
|
} else {
|
|
206
144
|
const seen = new Set();
|
|
207
145
|
input.capabilities.forEach((capability, index) => {
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
)
|
|
212
|
-
addIssue(
|
|
213
|
-
issues,
|
|
214
|
-
`capabilities[${index}]`,
|
|
215
|
-
"deve ser um identificador namespaced, por exemplo core.collections.",
|
|
216
|
-
);
|
|
217
|
-
} else if (seen.has(capability)) {
|
|
218
|
-
addIssue(issues, `capabilities[${index}]`, `capability duplicada: ${capability}.`);
|
|
219
|
-
} else {
|
|
220
|
-
seen.add(capability);
|
|
221
|
-
}
|
|
146
|
+
if (typeof capability !== "string" || !/^[a-z][a-z0-9]*(?:[._:-][a-z0-9][a-z0-9-]*)+$/.test(capability)) {
|
|
147
|
+
addIssue(issues, `capabilities[${index}]`, "deve ser um identificador namespaced, por exemplo core.collections.");
|
|
148
|
+
} else if (seen.has(capability)) addIssue(issues, `capabilities[${index}]`, `capability duplicada: ${capability}.`);
|
|
149
|
+
else seen.add(capability);
|
|
222
150
|
});
|
|
223
151
|
}
|
|
224
|
-
|
|
225
152
|
validateCompatibility(input.compatibility, issues, coreVersion);
|
|
226
153
|
validateActivation(input.activation, issues);
|
|
227
|
-
|
|
154
|
+
validateRbac(input.rbac, issues);
|
|
228
155
|
if (issues.length) throw new AppManifestError("central.app.json inválido.", issues);
|
|
229
156
|
return JSON.parse(JSON.stringify(input));
|
|
230
157
|
}
|
|
@@ -233,9 +160,7 @@ function resolveAppManifestPath(cwd = process.cwd()) {
|
|
|
233
160
|
const absolute = path.resolve(cwd);
|
|
234
161
|
const candidates = [path.join(absolute, APP_MANIFEST_FILENAME)];
|
|
235
162
|
const parent = path.dirname(absolute);
|
|
236
|
-
if (["backend", "frontend"].includes(path.basename(absolute)))
|
|
237
|
-
candidates.push(path.join(parent, APP_MANIFEST_FILENAME));
|
|
238
|
-
}
|
|
163
|
+
if (["backend", "frontend"].includes(path.basename(absolute))) candidates.push(path.join(parent, APP_MANIFEST_FILENAME));
|
|
239
164
|
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
|
240
165
|
}
|
|
241
166
|
|
|
@@ -254,6 +179,7 @@ function appManifestToConfig(manifest, manifestPath) {
|
|
|
254
179
|
},
|
|
255
180
|
modules: { ...manifest.modules },
|
|
256
181
|
capabilities: [...manifest.capabilities],
|
|
182
|
+
rbac: manifest.rbac ? JSON.parse(JSON.stringify(manifest.rbac)) : undefined,
|
|
257
183
|
appManifest: manifest,
|
|
258
184
|
appManifestPath: manifestPath,
|
|
259
185
|
};
|
|
@@ -266,17 +192,13 @@ function registerAppManifest(input, options = {}) {
|
|
|
266
192
|
}
|
|
267
193
|
|
|
268
194
|
function loadAppManifest(cwdOrFile = process.cwd(), options = {}) {
|
|
269
|
-
const manifestPath = cwdOrFile.endsWith?.(".json")
|
|
270
|
-
? path.resolve(cwdOrFile)
|
|
271
|
-
: resolveAppManifestPath(cwdOrFile);
|
|
195
|
+
const manifestPath = cwdOrFile.endsWith?.(".json") ? path.resolve(cwdOrFile) : resolveAppManifestPath(cwdOrFile);
|
|
272
196
|
let input;
|
|
273
197
|
try {
|
|
274
198
|
input = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
275
199
|
} catch (error) {
|
|
276
200
|
if (error.code === "ENOENT") throw error;
|
|
277
|
-
throw new AppManifestError("Não foi possível ler central.app.json.", [
|
|
278
|
-
{ path: "$", message: error.message },
|
|
279
|
-
], { cause: error });
|
|
201
|
+
throw new AppManifestError("Não foi possível ler central.app.json.", [{ path: "$", message: error.message }], { cause: error });
|
|
280
202
|
}
|
|
281
203
|
return registerAppManifest(input, { ...options, manifestPath });
|
|
282
204
|
}
|