@oondemand/oon-core-back 0.3.74 → 0.3.76
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/errors/GenericError.js +3 -2
- package/src/core/factories/crudController.js +96 -27
- package/src/core/factories/crudService.js +101 -22
- package/src/core/factories/modelRouter.js +18 -15
- package/src/core/middlewares/auth.js +72 -4
- 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/core/tenancy.js +200 -0
- package/src/index.js +19 -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
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const GenericError = require("./errors/GenericError");
|
|
2
|
+
|
|
3
|
+
const TENANT_HEADER = "x-oon-tenant-id";
|
|
4
|
+
const TENANCY_MODELS = Object.freeze(["none", "single_tenant", "multi_tenant"]);
|
|
5
|
+
const DATA_SCOPES = Object.freeze(["legacy", "system", "tenant", "user"]);
|
|
6
|
+
|
|
7
|
+
function stringId(value) {
|
|
8
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
9
|
+
if (typeof value === "object") {
|
|
10
|
+
if (value.id !== undefined) return stringId(value.id);
|
|
11
|
+
if (value._id !== undefined) return stringId(value._id);
|
|
12
|
+
}
|
|
13
|
+
return String(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function forbidden(code, message) {
|
|
17
|
+
return new GenericError(message, { statusCode: 403, code });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readRequestedTenantId(req) {
|
|
21
|
+
return stringId(req?.headers?.[TENANT_HEADER]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function inferVerifiedTenantId(usuario) {
|
|
25
|
+
return stringId(
|
|
26
|
+
usuario?.tenantId ||
|
|
27
|
+
usuario?.tenant?.id ||
|
|
28
|
+
usuario?.tenant?._id ||
|
|
29
|
+
usuario?.acesso?.tenant?.id ||
|
|
30
|
+
usuario?.acesso?.tenant?._id ||
|
|
31
|
+
usuario?.licenca?.tenantId ||
|
|
32
|
+
usuario?.aplicativo?.tenantId,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function inferTenancyModel(usuario, fallback = "single_tenant") {
|
|
37
|
+
const value =
|
|
38
|
+
usuario?.tenancyModel ||
|
|
39
|
+
usuario?.app?.tenancyModel ||
|
|
40
|
+
usuario?.aplicativo?.tenancyModel ||
|
|
41
|
+
usuario?.acesso?.app?.tenancyModel ||
|
|
42
|
+
fallback;
|
|
43
|
+
return TENANCY_MODELS.includes(value) ? value : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function inferPermissions(usuario) {
|
|
47
|
+
const raw = usuario?.permissions || usuario?.permissoes || [];
|
|
48
|
+
if (Array.isArray(raw)) return [...new Set(raw.map(String))];
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createAccessContext(usuario, options = {}) {
|
|
53
|
+
const requestedTenantId = stringId(options.requestedTenantId);
|
|
54
|
+
const verifiedTenantId = inferVerifiedTenantId(usuario);
|
|
55
|
+
const tenancyModel = inferTenancyModel(usuario, options.tenancyModel || "single_tenant");
|
|
56
|
+
|
|
57
|
+
// O header seleciona um tenant, mas nunca é autoridade. A autoridade é o
|
|
58
|
+
// tenant devolvido pelo provedor de identidade/Control Plane após validar o
|
|
59
|
+
// vínculo do usuário com o app e o tenant solicitado.
|
|
60
|
+
if (requestedTenantId && verifiedTenantId && requestedTenantId !== verifiedTenantId) {
|
|
61
|
+
throw forbidden(
|
|
62
|
+
"TENANT_CONTEXT_MISMATCH",
|
|
63
|
+
"O tenant solicitado não corresponde ao contexto autorizado.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (tenancyModel === "multi_tenant" && !verifiedTenantId) {
|
|
68
|
+
throw forbidden(
|
|
69
|
+
"TENANT_CONTEXT_REQUIRED",
|
|
70
|
+
"Selecione um tenant autorizado para acessar este aplicativo.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (tenancyModel === "none" && requestedTenantId) {
|
|
75
|
+
throw forbidden(
|
|
76
|
+
"TENANT_CONTEXT_NOT_ALLOWED",
|
|
77
|
+
"Este aplicativo não aceita contexto de tenant.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const roleCode = String(
|
|
82
|
+
usuario?.roleCode || usuario?.perfil || usuario?.tipo || "",
|
|
83
|
+
).trim() || undefined;
|
|
84
|
+
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
userId: stringId(usuario?.usuarioId || usuario?.id || usuario?._id || usuario?.sub),
|
|
87
|
+
tenantId: tenancyModel === "none" ? undefined : verifiedTenantId,
|
|
88
|
+
appCode: String(
|
|
89
|
+
usuario?.appCode ||
|
|
90
|
+
usuario?.app?.codigo ||
|
|
91
|
+
usuario?.aplicativo?.codigo ||
|
|
92
|
+
usuario?.acesso?.app?.codigo ||
|
|
93
|
+
options.appCode ||
|
|
94
|
+
"",
|
|
95
|
+
).trim().toLowerCase() || undefined,
|
|
96
|
+
roleCode,
|
|
97
|
+
permissions: Object.freeze(inferPermissions(usuario)),
|
|
98
|
+
tenancyModel,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function dataScope(entryOrDefinition) {
|
|
103
|
+
const definition = entryOrDefinition?.definition || entryOrDefinition || {};
|
|
104
|
+
const value = definition.scope || definition.tenancy?.scope || "legacy";
|
|
105
|
+
return DATA_SCOPES.includes(value) ? value : "legacy";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tenantField(entryOrDefinition) {
|
|
109
|
+
const definition = entryOrDefinition?.definition || entryOrDefinition || {};
|
|
110
|
+
return definition.tenancy?.tenantField || "tenantId";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function userField(entryOrDefinition) {
|
|
114
|
+
const definition = entryOrDefinition?.definition || entryOrDefinition || {};
|
|
115
|
+
return definition.tenancy?.userField || "usuarioId";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function requireContextValue(value, code, message) {
|
|
119
|
+
if (!value) throw forbidden(code, message);
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scopeFilter(entryOrDefinition, accessContext) {
|
|
124
|
+
const scope = dataScope(entryOrDefinition);
|
|
125
|
+
if (scope === "legacy" || scope === "system") return {};
|
|
126
|
+
|
|
127
|
+
const tenantId = requireContextValue(
|
|
128
|
+
accessContext?.tenantId,
|
|
129
|
+
"TENANT_CONTEXT_REQUIRED",
|
|
130
|
+
"Contexto de tenant obrigatório para este recurso.",
|
|
131
|
+
);
|
|
132
|
+
const filter = { [tenantField(entryOrDefinition)]: tenantId };
|
|
133
|
+
|
|
134
|
+
if (scope === "user") {
|
|
135
|
+
filter[userField(entryOrDefinition)] = requireContextValue(
|
|
136
|
+
accessContext?.userId,
|
|
137
|
+
"USER_CONTEXT_REQUIRED",
|
|
138
|
+
"Contexto de usuário obrigatório para este recurso.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return filter;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mergeScopedFilter(entryOrDefinition, filter = {}, accessContext) {
|
|
146
|
+
const clean = { ...(filter || {}) };
|
|
147
|
+
const enforced = scopeFilter(entryOrDefinition, accessContext);
|
|
148
|
+
|
|
149
|
+
// Remove tentativas de substituir escopo via query/body. O valor confiável
|
|
150
|
+
// sempre vence e é derivado de req.accessContext.
|
|
151
|
+
for (const [key, value] of Object.entries(enforced)) {
|
|
152
|
+
clean[key] = value;
|
|
153
|
+
}
|
|
154
|
+
return clean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function scopeMutation(entryOrDefinition, data = {}, accessContext) {
|
|
158
|
+
const result = data && typeof data === "object" && !Array.isArray(data)
|
|
159
|
+
? { ...data }
|
|
160
|
+
: data;
|
|
161
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
|
|
162
|
+
|
|
163
|
+
const scope = dataScope(entryOrDefinition);
|
|
164
|
+
if (scope === "legacy" || scope === "system") return result;
|
|
165
|
+
|
|
166
|
+
const enforced = scopeFilter(entryOrDefinition, accessContext);
|
|
167
|
+
Object.assign(result, enforced);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function scopedIdFilter(entryOrDefinition, id, accessContext) {
|
|
172
|
+
return mergeScopedFilter(entryOrDefinition, { _id: id }, accessContext);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function assertScopedEntry(entryOrDefinition) {
|
|
176
|
+
const scope = dataScope(entryOrDefinition);
|
|
177
|
+
if (scope === "legacy") return;
|
|
178
|
+
if (!DATA_SCOPES.includes(scope)) {
|
|
179
|
+
throw new Error(`Escopo de dados inválido: ${scope}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
TENANT_HEADER,
|
|
185
|
+
TENANCY_MODELS,
|
|
186
|
+
DATA_SCOPES,
|
|
187
|
+
stringId,
|
|
188
|
+
readRequestedTenantId,
|
|
189
|
+
inferVerifiedTenantId,
|
|
190
|
+
inferTenancyModel,
|
|
191
|
+
createAccessContext,
|
|
192
|
+
dataScope,
|
|
193
|
+
tenantField,
|
|
194
|
+
userField,
|
|
195
|
+
scopeFilter,
|
|
196
|
+
mergeScopedFilter,
|
|
197
|
+
scopeMutation,
|
|
198
|
+
scopedIdFilter,
|
|
199
|
+
assertScopedEntry,
|
|
200
|
+
};
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,10 @@ const { activate } = require("./boot/activation");
|
|
|
6
6
|
const define = require("./core/define");
|
|
7
7
|
const fields = require("./core/fields");
|
|
8
8
|
const registry = require("./core/registry");
|
|
9
|
+
const tenancy = require("./core/tenancy");
|
|
9
10
|
const GenericError = require("./core/errors/GenericError");
|
|
11
|
+
const { requirePermission } = require("./core/middlewares/permissions");
|
|
12
|
+
const rbacPolicy = require("./core/rbac/policy");
|
|
10
13
|
const omieModule = require("./modules/omie");
|
|
11
14
|
const integrations = require("./modules/integrations");
|
|
12
15
|
const appManifest = require("./manifest/appManifest");
|
|
@@ -30,6 +33,22 @@ module.exports = {
|
|
|
30
33
|
defineValidation: define.defineValidation,
|
|
31
34
|
defineTrigger: define.defineTrigger,
|
|
32
35
|
|
|
36
|
+
requirePermission,
|
|
37
|
+
CORE_PERMISSIONS: rbacPolicy.CORE_PERMISSIONS,
|
|
38
|
+
rbacPolicy: rbacPolicy.policy,
|
|
39
|
+
roleByCode: rbacPolicy.roleByCode,
|
|
40
|
+
permissionGranted: rbacPolicy.granted,
|
|
41
|
+
|
|
42
|
+
TENANT_HEADER: tenancy.TENANT_HEADER,
|
|
43
|
+
TENANCY_MODELS: tenancy.TENANCY_MODELS,
|
|
44
|
+
DATA_SCOPES: tenancy.DATA_SCOPES,
|
|
45
|
+
createAccessContext: tenancy.createAccessContext,
|
|
46
|
+
readRequestedTenantId: tenancy.readRequestedTenantId,
|
|
47
|
+
scopeFilter: tenancy.scopeFilter,
|
|
48
|
+
mergeScopedFilter: tenancy.mergeScopedFilter,
|
|
49
|
+
scopeMutation: tenancy.scopeMutation,
|
|
50
|
+
scopedIdFilter: tenancy.scopedIdFilter,
|
|
51
|
+
|
|
33
52
|
APP_MANIFEST_FILENAME: appManifest.APP_MANIFEST_FILENAME,
|
|
34
53
|
APP_MANIFEST_SCHEMA_VERSION: appManifest.APP_MANIFEST_SCHEMA_VERSION,
|
|
35
54
|
SUPPORTED_APP_KINDS: appManifest.SUPPORTED_APP_KINDS,
|