@oondemand/oon-core-back 0.7.4 → 0.7.8
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/README.md +37 -0
- package/package.json +1 -1
- package/src/api/access.routes.js +41 -21
- package/src/api/core.routes.js +18 -10
- package/src/boot/loadCentral.js +11 -0
- package/src/capabilities/index.js +1 -0
- package/src/capabilities/secrets/index.js +113 -0
- package/src/core/define.js +17 -2
- package/src/core/middlewares/auth.js +8 -0
- package/src/core/middlewares/requestLog.js +3 -2
- package/src/core/rbac/centralInvitations.js +70 -0
- package/src/core/rbac/identityProvider.js +17 -2
- package/src/core/rbac/storage.js +4 -3
- package/src/delivery/runtime.js +13 -1
- package/src/delivery/secretsProvisioning.js +41 -0
- package/src/manifest/appManifest.js +8 -0
- package/src/manifest/processRuntime.js +5 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# OonCore Back - ativação de instâncias
|
|
2
2
|
|
|
3
|
+
Para criar ou alterar um App, comece pela entrada curta distribuída pelo gerador em `.ooncore/AGENTS.md` e pelo índice `.ooncore/docs/TASK_INDEX.md`. Bootstrap, rotas privadas, permissões, prefixos, erros e `sensitiveBody` estão consolidados em `.ooncore/docs/PUBLIC_CONTRACTS.md`. Esta referência detalhada é para consulta sob demanda.
|
|
4
|
+
|
|
3
5
|
## Runtime local desconectado
|
|
4
6
|
|
|
5
7
|
Com `NODE_ENV=development` e `OON_RUNTIME_MODE=local`, o Core escuta apenas em `127.0.0.1`, cria uma sessão técnica local de até 30 dias e não consulta a Central de Ativações. Códigos independentes e de uso único atendem navegador e automação/seed. Apps com tenant usam o contexto virtual fixo `local:tenant`, sem criar um recurso. O estado é `ativa_local`; nenhuma instância, licença, Deployment ou credencial operacional é criada. Produção, Kubernetes, bind externo e identidade operacional falham no startup.
|
|
@@ -91,3 +93,38 @@ defineRoutes("/integracao", (router) => {
|
|
|
91
93
|
```
|
|
92
94
|
|
|
93
95
|
O Core omite todo o corpo de entrada e resposta no `requestLog` para esse método e caminho, inclusive se a rota responder erro. Método, URL, status, usuário e correlationId continuam registrados. A opção não cifra o banco nem substitui autorização, validação e respostas sanitizadas. Nunca retorne o segredo pela rota, pelo erro ou pela auditoria da mutação. A opção não se aplica automaticamente a endpoints de CRUD gerados.
|
|
96
|
+
|
|
97
|
+
## Segredos V1 (0.7.6)
|
|
98
|
+
|
|
99
|
+
`capabilities.secrets.put/status/use/remove` armazena objetos cifrados para Apps single_tenant dedicados. Declare `core.secrets` e `capabilitySettings["core.secrets"]={enabled:true}`. Rotas exigem permissão e sensitiveBody; use `req.accessContext`. Chave provisionada fora do banco, preservada em republicações. Veja o guia distribuído `SECRETS.md` para assinaturas, limites e recadastro explícito.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
### Aceite de convite (C2, SDK 0.7.7)
|
|
103
|
+
|
|
104
|
+
`PUBLIC_APP_URL` deve conter a origem pública HTTPS do aplicativo, sem credenciais,
|
|
105
|
+
query, fragmento ou subcaminho. HTTP é permitido apenas em loopback fora de produção.
|
|
106
|
+
A configuração é validada antes de emitir/rotacionar o convite; o header Host não
|
|
107
|
+
é usado como destino. Novos links usam `/accept-invite#token=...`.
|
|
108
|
+
|
|
109
|
+
O frontend consulta `POST /core/invitations/inspect` com `{ token }`. Somente um
|
|
110
|
+
convite pendente válido permite consultar a existência da identidade. O aceite
|
|
111
|
+
usa `POST /core/invitations/accept` com `{ token, intent, password? }`:
|
|
112
|
+
`authenticate` verifica a senha atual; `register` cria uma identidade com senha
|
|
113
|
+
nova; `session` exige uma sessão válida do destinatário. Nenhum desses caminhos
|
|
114
|
+
redefine a senha de uma identidade existente. Recuperação de senha continua no
|
|
115
|
+
login da plataforma. O retorno após aceite é local e fixo, sem aceitar redirect
|
|
116
|
+
arbitrário. Os endpoints antigos continuam disponíveis até o cutover coordenado.
|
|
117
|
+
|
|
118
|
+
Provedores locais devem fornecer `auth.inspectInvitation({ req, invitation })`,
|
|
119
|
+
retornando `{ accountExists: boolean, sessionUser?: { id, email } | null }`, além de
|
|
120
|
+
`auth.provisionInvitation`. A sessão opcional deve ser validada como identidade
|
|
121
|
+
(assinatura, expiração, versão de credencial e escopo), sem exigir o acesso que
|
|
122
|
+
o convite ainda concederá. Uma sessão inválida retorna `sessionUser: null`.
|
|
123
|
+
O provisionamento recebe também `intent` e deve verificar a prova correspondente,
|
|
124
|
+
retornando `usuario` com `id` e `email` do destinatário. Sem hook de inspeção,
|
|
125
|
+
um provedor local falha fechado e nunca delega identidades administrativas ao
|
|
126
|
+
provedor de aplicativos.
|
|
127
|
+
|
|
128
|
+
Publicar primeiro o suporte da Central (consulta e prova de identidade), depois
|
|
129
|
+
lançar front/back do SDK juntos e atualizar consumidores. O bloqueio de integração
|
|
130
|
+
comercial permanece até C3a/C3b; C2 não executa cutover nem reenvia convites.
|
package/package.json
CHANGED
package/src/api/access.routes.js
CHANGED
|
@@ -22,9 +22,10 @@ const {
|
|
|
22
22
|
recoverPendingInvitation,
|
|
23
23
|
assertNotLastAdmin,
|
|
24
24
|
} = require("../core/rbac/storage");
|
|
25
|
-
const { provisionInvitation } = require("../core/rbac/identityProvider");
|
|
25
|
+
const { provisionInvitation, inspectInvitation } = require("../core/rbac/identityProvider");
|
|
26
26
|
|
|
27
27
|
const router = express.Router();
|
|
28
|
+
router.use(require("../core/rbac/centralInvitations").createRouter({ inviteUrl }));
|
|
28
29
|
|
|
29
30
|
function requiredRbac() {
|
|
30
31
|
if (!policy().enabled) {
|
|
@@ -36,10 +37,16 @@ function requiredRbac() {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
function inviteUrl(req, token) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
let base;
|
|
41
|
+
try {
|
|
42
|
+
base = new URL(String(process.env.PUBLIC_APP_URL || ""));
|
|
43
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname);
|
|
44
|
+
if ((base.protocol !== "https:" && !(base.protocol === "http:" && local && process.env.NODE_ENV !== "production"))
|
|
45
|
+
|| base.username || base.password || base.search || base.hash || base.pathname !== "/") throw new Error();
|
|
46
|
+
} catch {
|
|
47
|
+
throw new GenericError("Configure PUBLIC_APP_URL com a origem HTTPS pública deste aplicativo.", { statusCode: 503, code: "INVITATION_PUBLIC_URL_INVALID" });
|
|
48
|
+
}
|
|
49
|
+
return `${base.origin}/accept-invite#token=${encodeURIComponent(token)}`;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
function invitationView(invitation) {
|
|
@@ -139,6 +146,7 @@ router.post(
|
|
|
139
146
|
asyncHandler(async (req, res) => {
|
|
140
147
|
requiredRbac();
|
|
141
148
|
const role = assertAssignable(req.usuario, req.body?.roleCode);
|
|
149
|
+
inviteUrl(req, ""); // Validate configuration before creating or rotating a token.
|
|
142
150
|
const result = await createInvitation({
|
|
143
151
|
email: req.body?.email,
|
|
144
152
|
name: req.body?.name,
|
|
@@ -192,6 +200,7 @@ router.post(
|
|
|
192
200
|
const previous = await Invitation.findOne({ _id: req.params.id, appCode: appCode() });
|
|
193
201
|
if (!previous) throw new GenericError("Convite não encontrado.", { statusCode: 404 });
|
|
194
202
|
assertAssignable(req.usuario, previous.roleCode);
|
|
203
|
+
inviteUrl(req, ""); // Validate configuration before creating or rotating a token.
|
|
195
204
|
const result = await createInvitation({
|
|
196
205
|
email: previous.email,
|
|
197
206
|
name: previous.name,
|
|
@@ -277,11 +286,9 @@ router.patch(
|
|
|
277
286
|
}),
|
|
278
287
|
);
|
|
279
288
|
|
|
280
|
-
|
|
281
|
-
"/invitations/:token",
|
|
282
|
-
asyncHandler(async (req, res) => {
|
|
289
|
+
const inspectHandler = asyncHandler(async (req, res) => {
|
|
283
290
|
requiredRbac();
|
|
284
|
-
const invitation = await findInvitationByToken(req.params.token);
|
|
291
|
+
const invitation = await findInvitationByToken(req.params.token || req.body?.token);
|
|
285
292
|
if (!invitation || invitation.status !== "pending") {
|
|
286
293
|
throw new GenericError("Convite inválido, expirado ou já utilizado.", {
|
|
287
294
|
statusCode: 410,
|
|
@@ -289,8 +296,12 @@ router.get(
|
|
|
289
296
|
});
|
|
290
297
|
}
|
|
291
298
|
res.set("Cache-Control", "no-store");
|
|
299
|
+
const identity = await inspectInvitation({ req, invitation });
|
|
300
|
+
if (typeof identity?.accountExists !== "boolean") throw new GenericError("Resposta inválida do provedor de identidade.", { statusCode: 502 });
|
|
292
301
|
res.json({
|
|
293
302
|
invitation: {
|
|
303
|
+
accountExists: identity.accountExists,
|
|
304
|
+
sessionUser: identity.sessionUser || null,
|
|
294
305
|
email: invitation.email,
|
|
295
306
|
name: invitation.name,
|
|
296
307
|
roleCode: invitation.roleCode,
|
|
@@ -298,19 +309,23 @@ router.get(
|
|
|
298
309
|
expiresAt: invitation.expiresAt,
|
|
299
310
|
},
|
|
300
311
|
});
|
|
301
|
-
})
|
|
302
|
-
);
|
|
312
|
+
});
|
|
313
|
+
router.get("/invitations/:token", inspectHandler);
|
|
314
|
+
router.post("/invitations/inspect", inspectHandler);
|
|
303
315
|
|
|
304
|
-
|
|
305
|
-
"/invitations/:token/accept",
|
|
306
|
-
asyncHandler(async (req, res) => {
|
|
316
|
+
const acceptHandler = asyncHandler(async (req, res) => {
|
|
307
317
|
requiredRbac();
|
|
308
318
|
const password = String(req.body?.password || "");
|
|
309
|
-
|
|
310
|
-
|
|
319
|
+
const intent = req.body?.intent;
|
|
320
|
+
if (intent !== undefined && !["register", "authenticate", "session"].includes(intent)) {
|
|
321
|
+
throw new GenericError("Forma de autenticação inválida.", { statusCode: 422 });
|
|
322
|
+
}
|
|
323
|
+
if ((intent === "register" || intent === undefined) && password.length < 8) {
|
|
324
|
+
throw new GenericError("A senha de cadastro deve possuir pelo menos 8 caracteres.", { statusCode: 422 });
|
|
311
325
|
}
|
|
326
|
+
if (intent !== "session" && !password) throw new GenericError("Informe a senha.", { statusCode: 422 });
|
|
312
327
|
|
|
313
|
-
const invitation = await findInvitationByToken(req.params.token);
|
|
328
|
+
const invitation = await findInvitationByToken(req.params.token || req.body?.token);
|
|
314
329
|
if (!invitation || invitation.status !== "pending") {
|
|
315
330
|
throw new GenericError("Convite inválido, expirado ou já utilizado.", {
|
|
316
331
|
statusCode: 410,
|
|
@@ -324,8 +339,12 @@ router.post(
|
|
|
324
339
|
throw new GenericError("O acesso associado ao convite não está mais disponível.", { statusCode: 410 });
|
|
325
340
|
}
|
|
326
341
|
|
|
327
|
-
const identity = await provisionInvitation({ req, invitation, password });
|
|
342
|
+
const identity = await provisionInvitation({ req, invitation, password, intent });
|
|
328
343
|
const providerUser = identity?.usuario || identity?.user || identity;
|
|
344
|
+
if (!providerUser?.email || String(providerUser.email).trim().toLowerCase() !== invitation.email.toLowerCase()
|
|
345
|
+
|| !(providerUser.id || providerUser._id)) {
|
|
346
|
+
throw new GenericError("Identidade retornada não corresponde ao convite.", { statusCode: 502, code: "INVITATION_IDENTITY_MISMATCH" });
|
|
347
|
+
}
|
|
329
348
|
access.identityId = String(providerUser?.id || providerUser?._id || access.identityId || "") || undefined;
|
|
330
349
|
access.name = providerUser?.nome || providerUser?.name || access.name;
|
|
331
350
|
access.status = "active";
|
|
@@ -347,11 +366,12 @@ router.post(
|
|
|
347
366
|
|
|
348
367
|
res.set("Cache-Control", "no-store");
|
|
349
368
|
res.json({
|
|
350
|
-
message: "Convite aceito.
|
|
369
|
+
message: "Convite aceito. O acesso está ativo.",
|
|
351
370
|
email: access.email,
|
|
352
371
|
});
|
|
353
|
-
})
|
|
354
|
-
);
|
|
372
|
+
});
|
|
373
|
+
router.post("/invitations/:token/accept", acceptHandler);
|
|
374
|
+
router.post("/invitations/accept", acceptHandler);
|
|
355
375
|
|
|
356
376
|
module.exports = router;
|
|
357
377
|
module.exports.inviteUrl = inviteUrl;
|
package/src/api/core.routes.js
CHANGED
|
@@ -5,6 +5,11 @@ const fs = require("node:fs");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const registry = require("../core/registry");
|
|
7
7
|
const ctx = require("../core/context");
|
|
8
|
+
const { mergeScopedFilter } = require("../core/tenancy");
|
|
9
|
+
const { granted } = require("../core/rbac/policy");
|
|
10
|
+
const auth = require("../core/middlewares/auth");
|
|
11
|
+
const GenericError = require("../core/errors/GenericError");
|
|
12
|
+
const { effectiveRoles } = require("../core/middlewares/rbac");
|
|
8
13
|
const corePackage = require("../../package.json");
|
|
9
14
|
|
|
10
15
|
const router = express.Router();
|
|
@@ -115,9 +120,7 @@ const DASHBOARD_FILTER_OPERATORS = Object.freeze({
|
|
|
115
120
|
});
|
|
116
121
|
|
|
117
122
|
function dashboardError(message, statusCode = 422) {
|
|
118
|
-
|
|
119
|
-
error.statusCode = statusCode;
|
|
120
|
-
return error;
|
|
123
|
+
return new GenericError(message, { statusCode, code: "OON_DASHBOARD_REQUEST_REJECTED" });
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
function dashboardField(entry, field, usage) {
|
|
@@ -170,7 +173,7 @@ function normalizeDashboardFilter(entry, raw = {}) {
|
|
|
170
173
|
]));
|
|
171
174
|
}
|
|
172
175
|
|
|
173
|
-
async function executeDashboardMetric(entry, query = {}) {
|
|
176
|
+
async function executeDashboardMetric(entry, query = {}, accessContext) {
|
|
174
177
|
if (!entry?.mongooseModel) throw dashboardError("Model sem persistência para o Dashboard.");
|
|
175
178
|
const id = String(query.id || "").trim();
|
|
176
179
|
if (!id) throw dashboardError("Todo indicador do Dashboard deve possuir id.");
|
|
@@ -178,7 +181,7 @@ async function executeDashboardMetric(entry, query = {}) {
|
|
|
178
181
|
if (!["count", "sum", "groupCount", "groupSum"].includes(kind)) {
|
|
179
182
|
throw dashboardError(`Agregação de Dashboard não permitida: ${kind}.`);
|
|
180
183
|
}
|
|
181
|
-
const filter = normalizeDashboardFilter(entry, query.filters || {});
|
|
184
|
+
const filter = mergeScopedFilter(entry, normalizeDashboardFilter(entry, query.filters || {}), accessContext);
|
|
182
185
|
|
|
183
186
|
if (kind === "count") {
|
|
184
187
|
return { id, kind, value: Number(await entry.mongooseModel.countDocuments(filter)) };
|
|
@@ -222,13 +225,18 @@ async function executeDashboardMetric(entry, query = {}) {
|
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
function canReadDashboardModel(req, entry) {
|
|
225
|
-
|
|
228
|
+
if (!req.usuario) return false;
|
|
229
|
+
const crud = entry.definition?.crud || {};
|
|
230
|
+
if (crud.permissions?.read) return granted(req.usuario.permissions, crud.permissions.read);
|
|
231
|
+
const roles = crud.roles?.read || [];
|
|
226
232
|
if (!roles.length) return true;
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
if (req.usuario.commercialAccess && !req.usuario.commercialAccess.roleComplete) return false;
|
|
234
|
+
if (req.usuario.permissions?.includes("*")) return true;
|
|
235
|
+
const allowed = roles.map(role => String(role).trim().toLowerCase());
|
|
236
|
+
return effectiveRoles(req.usuario).some(role => allowed.includes(role));
|
|
229
237
|
}
|
|
230
238
|
|
|
231
|
-
router.post("/dashboard/query", async (req, res, next) => {
|
|
239
|
+
router.post("/dashboard/query", auth, async (req, res, next) => {
|
|
232
240
|
try {
|
|
233
241
|
const queries = req.body?.queries;
|
|
234
242
|
if (!Array.isArray(queries) || !queries.length) {
|
|
@@ -244,7 +252,7 @@ router.post("/dashboard/query", async (req, res, next) => {
|
|
|
244
252
|
if (!canReadDashboardModel(req, entry)) {
|
|
245
253
|
throw dashboardError(`Sem permissão de leitura para ${entry.name}.`, 403);
|
|
246
254
|
}
|
|
247
|
-
return executeDashboardMetric(entry, query);
|
|
255
|
+
return executeDashboardMetric(entry, query, req.accessContext);
|
|
248
256
|
}));
|
|
249
257
|
res.json({ results });
|
|
250
258
|
} catch (error) {
|
package/src/boot/loadCentral.js
CHANGED
|
@@ -70,6 +70,17 @@ function assertConfigBoundary(config, appManifest) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function loadCentral(cwd = process.cwd()) {
|
|
73
|
+
cwd = path.resolve(cwd);
|
|
74
|
+
// start({ cwd }) always points to the backend, not the App root.
|
|
75
|
+
// Fail locally instead of starting a healthy server with no domain routes.
|
|
76
|
+
if (fs.existsSync(path.join(cwd, APP_MANIFEST_FILENAME))
|
|
77
|
+
&& fs.existsSync(path.join(cwd, "backend", "src"))
|
|
78
|
+
&& !fs.existsSync(path.join(cwd, "src"))
|
|
79
|
+
&& !fs.existsSync(path.join(cwd, "central.config.js"))) {
|
|
80
|
+
const error = new Error(`OON_BACKEND_CWD_INVALID: ${cwd} é a raiz do App. Use start({ cwd: path.join(appRoot, "backend") }) ou execute o CLI em backend/.`);
|
|
81
|
+
error.code = "OON_BACKEND_CWD_INVALID";
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
73
84
|
let appManifest;
|
|
74
85
|
const appManifestPath = resolveAppManifestPath(cwd);
|
|
75
86
|
if (fs.existsSync(appManifestPath)) {
|
|
@@ -6,6 +6,7 @@ const transactionalEmail = require("./transactionalEmail");
|
|
|
6
6
|
const pdf = Object.freeze({ render: pdfRendering.render });
|
|
7
7
|
|
|
8
8
|
module.exports = Object.freeze({
|
|
9
|
+
secrets: require("./secrets"),
|
|
9
10
|
pdf,
|
|
10
11
|
pdfRendering: pdf,
|
|
11
12
|
configurePdfRendering: pdfRendering.configurePdfRendering,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const crypto = require("node:crypto");
|
|
3
|
+
const mongoose = require("mongoose");
|
|
4
|
+
const ctx = require("../../core/context");
|
|
5
|
+
const GenericError = require("../../core/errors/GenericError");
|
|
6
|
+
const MAX_BYTES = 16384;
|
|
7
|
+
function fail(code, statusCode = 409) {
|
|
8
|
+
return new GenericError({ SECRETS_UNAVAILABLE: "Restabeleça o provisionamento da chave antes de recadastrar.", SECRETS_REENTRY_REQUIRED: "Recadastre explicitamente a credencial.", SECRETS_NOT_FOUND: "Credencial não cadastrada.", SECRETS_USE_FAILED: "A operação com a credencial falhou." }[code] || "Operação de segredos recusada; confira declaração, contexto e parâmetros.", { code, statusCode });
|
|
9
|
+
}
|
|
10
|
+
function enabled() { return ctx.config.capabilities?.includes("core.secrets"); }
|
|
11
|
+
function resolveScope(context, name) {
|
|
12
|
+
if (!enabled()) throw fail("SECRETS_NOT_DECLARED");
|
|
13
|
+
if (ctx.config.tenancyModel !== "single_tenant" || ctx.config.deploymentMode !== "dedicated") throw fail("SECRETS_UNSUPPORTED_RUNTIME");
|
|
14
|
+
if (typeof name !== "string" || !/^[a-z][a-z0-9._-]{0,63}$/.test(name)) throw fail("SECRETS_INVALID_NAME", 422);
|
|
15
|
+
const { OON_SECRETS_INSTANCE: instance, APP_ENVIRONMENT: environment, OON_SECRETS_TENANT: tenantId } = process.env;
|
|
16
|
+
const appCode = ctx.config.appCode;
|
|
17
|
+
if (!instance || !environment || !tenantId || !appCode) throw fail("SECRETS_UNAVAILABLE");
|
|
18
|
+
if (!context?.userId || context.tenancyModel !== "single_tenant" || context.tenantId !== tenantId || context.appCode !== appCode) throw fail("SECRETS_SCOPE_DENIED", 403);
|
|
19
|
+
return { scope: JSON.stringify([appCode, instance, environment, tenantId]), name };
|
|
20
|
+
}
|
|
21
|
+
function key() {
|
|
22
|
+
const value = process.env.OON_SECRETS_KEY;
|
|
23
|
+
if (!value || !/^[A-Za-z0-9+/]{43}=$/.test(value)) throw fail("SECRETS_UNAVAILABLE");
|
|
24
|
+
const result = Buffer.from(value, "base64");
|
|
25
|
+
if (result.length !== 32 || result.toString("base64") !== value) throw fail("SECRETS_UNAVAILABLE");
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function models() {
|
|
29
|
+
if (!mongoose.models.OonSecret) {
|
|
30
|
+
const schema = new mongoose.Schema({ _id: String, scope: String, name: String, envelope: mongoose.Schema.Types.Mixed }, { versionKey: false, timestamps: true });
|
|
31
|
+
mongoose.model("OonSecret", schema, "oon_secrets");
|
|
32
|
+
mongoose.model("OonSecretAudit", new mongoose.Schema({ operation: String, actorId: String, scope: String, name: String, correlationId: String, at: Date }, { versionKey: false }), "oon_secret_audit");
|
|
33
|
+
}
|
|
34
|
+
return { Secret: mongoose.model("OonSecret"), Audit: mongoose.model("OonSecretAudit") };
|
|
35
|
+
}
|
|
36
|
+
function id(s) { return crypto.createHash("sha256").update(JSON.stringify([s.scope, s.name])).digest("hex"); }
|
|
37
|
+
function aad(s) { return Buffer.from(JSON.stringify([1, "AES-256-GCM", s.scope, s.name])); }
|
|
38
|
+
function decode(envelope, s) {
|
|
39
|
+
const k = key();
|
|
40
|
+
let plain;
|
|
41
|
+
try {
|
|
42
|
+
if (envelope?.version !== 1 || envelope.algorithm !== "AES-256-GCM" || !/^[a-f0-9]{24}$/.test(envelope.iv) || !/^[a-f0-9]{32}$/.test(envelope.tag) || typeof envelope.ciphertext !== "string" || envelope.ciphertext.length > MAX_BYTES * 2 || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope.ciphertext) || Buffer.from(envelope.ciphertext, "base64").toString("base64") !== envelope.ciphertext) throw Error();
|
|
43
|
+
const cipher = crypto.createDecipheriv("aes-256-gcm", k, Buffer.from(envelope.iv, "hex"));
|
|
44
|
+
cipher.setAAD(aad(s)); cipher.setAuthTag(Buffer.from(envelope.tag, "hex"));
|
|
45
|
+
plain = Buffer.concat([cipher.update(Buffer.from(envelope.ciphertext, "base64")), cipher.final()]);
|
|
46
|
+
return JSON.parse(plain.toString("utf8"));
|
|
47
|
+
} catch { throw fail("SECRETS_REENTRY_REQUIRED"); }
|
|
48
|
+
finally { k.fill(0); plain?.fill(0); }
|
|
49
|
+
}
|
|
50
|
+
async function audit(operation, s, context, correlationId) {
|
|
51
|
+
if (correlationId !== undefined && (typeof correlationId !== "string" || !/^[a-zA-Z0-9:_-]{1,128}$/.test(correlationId))) throw fail("SECRETS_INVALID_CORRELATION", 422);
|
|
52
|
+
await models().Audit.create({ operation, actorId: String(context.userId), ...s, correlationId, at: new Date() });
|
|
53
|
+
}
|
|
54
|
+
async function safe(operation) {
|
|
55
|
+
try { return await operation(); }
|
|
56
|
+
catch (error) { if (error instanceof GenericError && error.code?.startsWith("SECRETS_")) throw error; throw fail("SECRETS_STORAGE_UNAVAILABLE", 503); }
|
|
57
|
+
}
|
|
58
|
+
async function status({ name, context, correlationId } = {}) {
|
|
59
|
+
return safe(async () => {
|
|
60
|
+
const s = resolveScope(context, name);
|
|
61
|
+
await audit("status", s, context, correlationId);
|
|
62
|
+
const row = await models().Secret.findById(id(s)).lean();
|
|
63
|
+
try { const k = key(); k.fill(0); } catch { return { state: "reentry_required", reason: "key_unavailable", action: "restore_provisioning" }; }
|
|
64
|
+
if (!row) return { state: "absent" };
|
|
65
|
+
try { decode(row.envelope, s); return { state: "available" }; }
|
|
66
|
+
catch { return { state: "reentry_required", reason: "incompatible_or_unreadable", action: "replace" }; }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function put({ name, value, context, correlationId, replace = false } = {}) {
|
|
70
|
+
return safe(async () => {
|
|
71
|
+
const s = resolveScope(context, name);
|
|
72
|
+
let plain;
|
|
73
|
+
try {
|
|
74
|
+
if (!value || Object.getPrototypeOf(value) !== Object.prototype) throw Error();
|
|
75
|
+
plain = Buffer.from(JSON.stringify(value));
|
|
76
|
+
const parsed = JSON.parse(plain.toString("utf8"));
|
|
77
|
+
if (plain.length > MAX_BYTES || !parsed || Array.isArray(parsed) || typeof parsed !== "object" || !Object.keys(parsed).length) throw Error();
|
|
78
|
+
} catch { plain?.fill(0); throw fail("SECRETS_INVALID_VALUE", 422); }
|
|
79
|
+
let k;
|
|
80
|
+
try {
|
|
81
|
+
k = key();
|
|
82
|
+
const existing = await models().Secret.findById(id(s)).lean();
|
|
83
|
+
if (existing && replace !== true) { try { decode(existing.envelope, s); } catch { throw fail("SECRETS_REENTRY_REQUIRED"); } }
|
|
84
|
+
const iv = crypto.randomBytes(12);
|
|
85
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", k, iv); cipher.setAAD(aad(s));
|
|
86
|
+
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]).toString("base64");
|
|
87
|
+
const envelope = { version: 1, algorithm: "AES-256-GCM", iv: iv.toString("hex"), tag: cipher.getAuthTag().toString("hex"), ciphertext };
|
|
88
|
+
await audit("put", s, context, correlationId);
|
|
89
|
+
// Deterministic _id makes concurrent first writes use Mongo's unique primary key.
|
|
90
|
+
try { await models().Secret.updateOne({ _id: id(s) }, { $set: { ...s, envelope } }, { upsert: true }); }
|
|
91
|
+
catch (error) { if (error.code !== 11000) throw error; await models().Secret.updateOne({ _id: id(s) }, { $set: { ...s, envelope } }, { upsert: true }); }
|
|
92
|
+
return { state: "available" };
|
|
93
|
+
} finally { k?.fill(0); plain.fill(0); }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function use({ name, context, correlationId } = {}, callback) {
|
|
97
|
+
const value = await safe(async () => {
|
|
98
|
+
const s = resolveScope(context, name);
|
|
99
|
+
const row = await models().Secret.findById(id(s)).lean();
|
|
100
|
+
if (!row) throw fail("SECRETS_NOT_FOUND");
|
|
101
|
+
const value = decode(row.envelope, s);
|
|
102
|
+
await audit("use", s, context, correlationId);
|
|
103
|
+
return value;
|
|
104
|
+
});
|
|
105
|
+
try { if (typeof callback !== "function") throw Error(); await callback(value); }
|
|
106
|
+
catch { throw fail("SECRETS_USE_FAILED", 502); }
|
|
107
|
+
finally { for (const name of Object.keys(value)) { try { delete value[name]; } catch { /* consumer may freeze retained copies */ } } }
|
|
108
|
+
// No callback return value crosses this API boundary.
|
|
109
|
+
}
|
|
110
|
+
async function remove({ name, context, correlationId } = {}) {
|
|
111
|
+
return safe(async () => { const s = resolveScope(context, name); await audit("remove", s, context, correlationId); await models().Secret.deleteOne({ _id: id(s) }); return { state: "absent" }; });
|
|
112
|
+
}
|
|
113
|
+
module.exports = Object.freeze({ put, status, use, remove });
|
package/src/core/define.js
CHANGED
|
@@ -150,13 +150,26 @@ function definePipeline(name, config) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function defineRoutes(basePath, builder) {
|
|
153
|
+
const invalid = (method, route, reason) => {
|
|
154
|
+
const error = new TypeError(`defineRoutes(${String(basePath)}) ${method} ${String(route)}: ${reason} Use router.private.post("/rota", { permission: "recurso.write" }, handler) ou ("/rota", handler).`);
|
|
155
|
+
error.code = "OON_ROUTE_REGISTRATION_INVALID";
|
|
156
|
+
throw error;
|
|
157
|
+
};
|
|
158
|
+
if (typeof builder !== "function") invalid("builder", "", "builder deve ser uma função.");
|
|
153
159
|
const expressRouter = express.Router();
|
|
154
160
|
|
|
155
|
-
const wrapPrivate = (method) => (
|
|
161
|
+
const wrapPrivate = (method) => (...args) => {
|
|
162
|
+
let [path, opts = {}, handler] = args;
|
|
163
|
+
if (args.length < 2 || args.length > 3) invalid(method, path, "São aceitos dois ou três argumentos.");
|
|
164
|
+
if (typeof opts === "function" && args.length === 3) {
|
|
165
|
+
invalid(method, path, "Middleware na posição de opções descartaria o handler final.");
|
|
166
|
+
}
|
|
156
167
|
if (typeof opts === "function") {
|
|
157
168
|
handler = opts;
|
|
158
169
|
opts = {};
|
|
159
170
|
}
|
|
171
|
+
if (!opts || typeof opts !== "object" || Array.isArray(opts)) invalid(method, path, "Opções devem ser um objeto.");
|
|
172
|
+
if (typeof handler !== "function") invalid(method, path, "Handler ausente ou inválido.");
|
|
160
173
|
const chain = [auth];
|
|
161
174
|
const required = opts.permission || opts.permissions;
|
|
162
175
|
if (required) chain.push(requirePermission(required));
|
|
@@ -170,7 +183,9 @@ function defineRoutes(basePath, builder) {
|
|
|
170
183
|
expressRouter[method](path, ...chain, asyncHandler(handler));
|
|
171
184
|
};
|
|
172
185
|
|
|
173
|
-
const wrapPublic = (method) => (
|
|
186
|
+
const wrapPublic = (method) => (...args) => {
|
|
187
|
+
const [path, handler] = args;
|
|
188
|
+
if (args.length !== 2 || typeof handler !== "function") invalid(method, path, "Rota pública exige (path, handler).");
|
|
174
189
|
expressRouter[method](path, asyncHandler(handler));
|
|
175
190
|
};
|
|
176
191
|
|
|
@@ -137,6 +137,14 @@ async function applyEffectiveAccess(usuario) {
|
|
|
137
137
|
rbacMode: "app-commercial", commercialAccess: { ...authority,
|
|
138
138
|
roleComplete: !local.includes("*") && local.every(p => permissions.includes(p)) } };
|
|
139
139
|
}
|
|
140
|
+
if (usuario?.canonicalAccess && typeof ctx.config.auth?.verifyToken !== "function") {
|
|
141
|
+
const source = usuario.canonicalAccess;
|
|
142
|
+
const role = roleByCode(source.roleCode);
|
|
143
|
+
if (!source.grantId || source.tenantId !== usuario.tenantId || !role) {
|
|
144
|
+
throw Object.assign(Error("Concessão central inválida."), { statusCode: 403, code: "CENTRAL_ACCESS_INVALID" });
|
|
145
|
+
}
|
|
146
|
+
return { ...usuario, roleCode: role.code, permissions: rolePermissions(role), accessStatus: "active", mustChangePassword: false, rbacMode: "central-invitation" };
|
|
147
|
+
}
|
|
140
148
|
const access = await resolveUserAccess(usuario);
|
|
141
149
|
return {
|
|
142
150
|
...usuario,
|
|
@@ -8,10 +8,11 @@ const registry = require("../registry");
|
|
|
8
8
|
const requestLog = async (req, res, next) => {
|
|
9
9
|
if (req.method === "GET") return next();
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const invitationRequest = /\/(?:core\/invitations|convites)\//.test(String(req.originalUrl || req.url));
|
|
12
|
+
const sensitiveBody = invitationRequest || await registry.isSensitiveRequest(req.method, req.url || req.originalUrl);
|
|
12
13
|
const log = new Log({
|
|
13
14
|
usuario: req.usuario || null,
|
|
14
|
-
endpoint: req.originalUrl,
|
|
15
|
+
endpoint: invitationRequest ? String(req.originalUrl).replace(/(\/core\/invitations\/)[^/?]+/, "$1[redacted]").split("?")[0] : req.originalUrl,
|
|
15
16
|
metodo: req.method,
|
|
16
17
|
ip: req.ip || req.headers["x-forwarded-for"] || req.connection?.remoteAddress,
|
|
17
18
|
correlationId: req.correlationId,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const express = require("express");
|
|
3
|
+
const ctx = require("../context");
|
|
4
|
+
const auth = require("../middlewares/auth");
|
|
5
|
+
const { asyncHandler } = require("../utils/helpers");
|
|
6
|
+
const GenericError = require("../errors/GenericError");
|
|
7
|
+
const { defaultProviderCall } = require("./identityProvider");
|
|
8
|
+
const { models, appCode, audit } = require("./storage");
|
|
9
|
+
const base = "/integracao/autenticacao-aplicativos/convites/v1";
|
|
10
|
+
function enabled() { return process.env.OON_CENTRAL_INVITATIONS_ENABLED === "true" && typeof ctx.config.auth?.provisionInvitation !== "function" && typeof ctx.config.auth?.verifyToken !== "function" && process.env.OON_RUNTIME_MODE !== "local"; }
|
|
11
|
+
async function cutover() {
|
|
12
|
+
// No import or token recovery. Idempotent state transition touches only old pending invitations.
|
|
13
|
+
const { Invitation } = models();
|
|
14
|
+
const session = await Invitation.db.startSession();
|
|
15
|
+
try { await session.withTransaction(async () => {
|
|
16
|
+
const rows = await Invitation.find({ appCode: appCode(), status: "pending" }).session(session);
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
const changed = await Invitation.updateOne({ _id: row._id, status: "pending" }, { $set: { status: "revoked", revokedAt: new Date() } }, { session });
|
|
19
|
+
if (changed.modifiedCount) await audit("invitation.cutover.v1", { actor: "system:cutover", target: row.email, context: { invitationId: String(row._id), cutoverId: "central-invitations-v1" }, session });
|
|
20
|
+
}
|
|
21
|
+
}); } finally { await session.endSession(); }
|
|
22
|
+
}
|
|
23
|
+
function createRouter({ inviteUrl }) {
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
router.use((req, _res, next) => enabled() ? next() : next("router"));
|
|
26
|
+
const call = (req, payload) => defaultProviderCall(req, `${base}/command`, payload);
|
|
27
|
+
const management = handler => [auth, asyncHandler(async (req, res) => { await call(req, { command: "list" }); await cutover(); res.set("Cache-Control", "no-store"); await handler(req, res); })];
|
|
28
|
+
function identifier(value) {
|
|
29
|
+
const match = /^central:([a-f\d]{24})$/i.exec(String(value));
|
|
30
|
+
if (!match) throw new GenericError("Convite anterior ao corte. Solicite um novo convite.", { statusCode: 410, code: "INVITATION_LEGACY_DISABLED" });
|
|
31
|
+
return match[1];
|
|
32
|
+
}
|
|
33
|
+
function invitation(row) { return { ...row, id: `central:${row.id}` }; }
|
|
34
|
+
const access = row => ({ id: `central:${row.id}`, email: row.email, name: row.name, roleCode: row.roleCode, status: row.status === "accepted" ? "active" : row.status === "pending" ? "invited" : "revoked", origin: "central", invitation: invitation(row) });
|
|
35
|
+
async function withLink(req, result) {
|
|
36
|
+
if (result.invitation.status && result.invitation.status !== "pending") return { invitation: invitation(result.invitation), access: access(result.invitation) };
|
|
37
|
+
const copied = await call(req, { command: "copy", id: result.invitation.id });
|
|
38
|
+
return { invitation: invitation(copied.invitation), access: access(copied.invitation), inviteUrl: inviteUrl(req, copied.token) };
|
|
39
|
+
}
|
|
40
|
+
router.get("/users", ...management(async (req, res) => {
|
|
41
|
+
const result = await call(req, { command: "list" });
|
|
42
|
+
const canonical = result.invitations.map(access);
|
|
43
|
+
const identities = new Set(canonical.map(row => row.email));
|
|
44
|
+
const local = req.usuario?.commercialAccess ? [] : await models().UserAccess.find({ appCode: appCode(), status: { $ne: "invited" } }).lean();
|
|
45
|
+
res.json({ results: [...canonical, ...local.filter(row => !identities.has(row.email)).map(row => ({ id: String(row._id), email: row.email, name: row.name, roleCode: row.roleCode, status: row.status, origin: "local" }))] });
|
|
46
|
+
}));
|
|
47
|
+
router.post("/users/invitations", ...management(async (req, res) => {
|
|
48
|
+
inviteUrl(req, "");
|
|
49
|
+
const result = await call(req, { command: "create", email: req.body?.email, roleCode: req.body?.roleCode, version: 0 });
|
|
50
|
+
res.status(201).json(await withLink(req, result));
|
|
51
|
+
}));
|
|
52
|
+
router.get("/users/invitations/:id/link", ...management(async (req, res) => res.json(await withLink(req, { invitation: { id: identifier(req.params.id) } }))));
|
|
53
|
+
router.post("/users/invitations/:id/regenerate", ...management(async (req, res) => {
|
|
54
|
+
inviteUrl(req, "");
|
|
55
|
+
res.json(await withLink(req, await call(req, { command: "reissue", id: identifier(req.params.id), version: req.body?.version })));
|
|
56
|
+
}));
|
|
57
|
+
router.delete("/users/invitations/:id", ...management(async (req, res) => res.json(await call(req, { command: "revoke", id: identifier(req.params.id), version: req.body?.version }))));
|
|
58
|
+
router.patch("/users/:id", auth, asyncHandler(async (req, _res, next) => { if (!String(req.params.id).startsWith("central:")) return next("router"); throw new GenericError("Gerencie a concessão na Central; esta lista é uma projeção de leitura.", { statusCode: 409, code: "CENTRAL_ACCESS_COMMAND_REQUIRED" }); }));
|
|
59
|
+
for (const action of ["inspect", "accept", "decline"]) router.post(`/invitations/${action}`, asyncHandler(async (req, res) => {
|
|
60
|
+
if (!String(req.body?.token || "").startsWith("c3.")) throw new GenericError("Convite anterior ao corte. Solicite um novo convite.", { statusCode: 410, code: "INVITATION_LEGACY_DISABLED" });
|
|
61
|
+
res.set("Cache-Control", "no-store");
|
|
62
|
+
if (action === "inspect") return res.json(await defaultProviderCall(req, `${base}/inspect`, { token: req.body.token }));
|
|
63
|
+
const result = await call(req, { command: action, token: req.body.token, version: req.body.version, intent: req.body.intent, password: req.body.password, registration: req.body.registration });
|
|
64
|
+
res.json({ ...result, message: "Convite processado pela Central." });
|
|
65
|
+
}));
|
|
66
|
+
router.all("/invitations/:token/accept", (_req, res) => res.status(410).json({ code: "INVITATION_LEGACY_DISABLED", message: "Solicite um novo convite." }));
|
|
67
|
+
router.get("/invitations/:token", (_req, res) => res.status(410).json({ code: "INVITATION_LEGACY_DISABLED", message: "Solicite um novo convite." }));
|
|
68
|
+
return router;
|
|
69
|
+
}
|
|
70
|
+
module.exports = { enabled, cutover, createRouter };
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
} = require("../utils/instanceActivation");
|
|
11
11
|
|
|
12
12
|
const IDENTITY_PATHS = Object.freeze({
|
|
13
|
+
inspectInvitation: "/integracao/autenticacao-aplicativos/convites/inspecionar",
|
|
13
14
|
provisionInvitation: "/integracao/autenticacao-aplicativos/convites/provisionar",
|
|
14
15
|
changeFirstAccessPassword: "/integracao/autenticacao-aplicativos/primeiro-acesso/senha",
|
|
15
16
|
});
|
|
@@ -25,6 +26,9 @@ async function defaultProviderCall(req, path, payload) {
|
|
|
25
26
|
|
|
26
27
|
try {
|
|
27
28
|
const headers = instanceHeaders(instance);
|
|
29
|
+
for (const key of ["idempotency-key", "x-correlation-id", "x-oon-tenant-id"]) {
|
|
30
|
+
if (req?.headers?.[key]) headers[key] = req.headers[key];
|
|
31
|
+
}
|
|
28
32
|
if (req?.headers?.authorization) headers.Authorization = req.headers.authorization;
|
|
29
33
|
const response = await providerPost(req, path, payload, headers);
|
|
30
34
|
return response.data;
|
|
@@ -38,14 +42,24 @@ async function defaultProviderCall(req, path, payload) {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
async function
|
|
45
|
+
async function inspectInvitation({ req, invitation }) {
|
|
46
|
+
const hook = ctx.config.auth?.inspectInvitation;
|
|
47
|
+
if (typeof hook === "function") return hook({ req, invitation });
|
|
48
|
+
if (typeof ctx.config.auth?.provisionInvitation === "function") {
|
|
49
|
+
throw new GenericError("O provedor local precisa disponibilizar a consulta de convite.", { statusCode: 503, code: "INVITATION_INSPECTION_REQUIRED" });
|
|
50
|
+
}
|
|
51
|
+
return defaultProviderCall(req, IDENTITY_PATHS.inspectInvitation, { email: invitation.email });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function provisionInvitation({ req, invitation, password, intent }) {
|
|
42
55
|
const hook = ctx.config.auth?.provisionInvitation;
|
|
43
|
-
if (typeof hook === "function") return hook({ req, invitation, password });
|
|
56
|
+
if (typeof hook === "function") return hook({ req, invitation, password, intent });
|
|
44
57
|
return defaultProviderCall(req, IDENTITY_PATHS.provisionInvitation, {
|
|
45
58
|
email: invitation.email,
|
|
46
59
|
nome: invitation.name,
|
|
47
60
|
roleCode: invitation.roleCode,
|
|
48
61
|
password,
|
|
62
|
+
intent,
|
|
49
63
|
invitationFingerprint: invitation.tokenFingerprint,
|
|
50
64
|
});
|
|
51
65
|
}
|
|
@@ -63,6 +77,7 @@ async function changeFirstAccessPassword({ req, user, password }) {
|
|
|
63
77
|
module.exports = {
|
|
64
78
|
IDENTITY_PATHS,
|
|
65
79
|
defaultProviderCall,
|
|
80
|
+
inspectInvitation,
|
|
66
81
|
provisionInvitation,
|
|
67
82
|
changeFirstAccessPassword,
|
|
68
83
|
};
|
package/src/core/rbac/storage.js
CHANGED
|
@@ -105,10 +105,10 @@ function sanitizeSnapshot(value) {
|
|
|
105
105
|
return plain;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
async function audit(event, { actor, target, before, after, context } = {}) {
|
|
108
|
+
async function audit(event, { actor, target, before, after, context, session } = {}) {
|
|
109
109
|
if (mongoose.connection.readyState !== 1) return null;
|
|
110
110
|
const { Audit } = models();
|
|
111
|
-
|
|
111
|
+
const data = {
|
|
112
112
|
appCode: appCode(),
|
|
113
113
|
event,
|
|
114
114
|
actor: typeof actor === "string" ? actor : actorKey(actor),
|
|
@@ -117,7 +117,8 @@ async function audit(event, { actor, target, before, after, context } = {}) {
|
|
|
117
117
|
after: sanitizeSnapshot(after),
|
|
118
118
|
context,
|
|
119
119
|
at: new Date(),
|
|
120
|
-
}
|
|
120
|
+
};
|
|
121
|
+
return session ? (await Audit.create([data], { session }))[0] : Audit.create(data);
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
function accessError(message, code = "RBAC_ACCESS_DENIED", statusCode = 403) {
|
package/src/delivery/runtime.js
CHANGED
|
@@ -6,6 +6,8 @@ const { kubectl } = require("./command");
|
|
|
6
6
|
const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
|
|
7
7
|
const { provisionMongo, validateMongoUri } = require("./mongoProvisioning");
|
|
8
8
|
|
|
9
|
+
const { provisionSecrets } = require("./secretsProvisioning");
|
|
10
|
+
|
|
9
11
|
const RESOURCE_PROFILES = {
|
|
10
12
|
nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
|
|
11
13
|
small: { cpuRequest: "100m", cpuLimit: "500m", memoryRequest: "128Mi", memoryLimit: "512Mi" },
|
|
@@ -111,6 +113,11 @@ spec:
|
|
|
111
113
|
imagePullPolicy: IfNotPresent
|
|
112
114
|
ports: [{ name: http, containerPort: 8080 }]
|
|
113
115
|
envFrom: [{ secretRef: { name: oon-runtime } }]
|
|
116
|
+
${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
|
|
117
|
+
- name: OON_SECRETS_KEY
|
|
118
|
+
valueFrom:
|
|
119
|
+
secretKeyRef: { name: oon-core-secrets, key: OON_SECRETS_KEY, optional: true }
|
|
120
|
+
` : ""}
|
|
114
121
|
resources:
|
|
115
122
|
requests: { cpu: ${resources.cpuRequest}, memory: ${resources.memoryRequest} }
|
|
116
123
|
limits: { cpu: ${resources.cpuLimit}, memory: ${resources.memoryLimit} }
|
|
@@ -171,6 +178,10 @@ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {
|
|
|
171
178
|
CENTRAL_ATIVACAO_API_URL: context.activationApiUrl,
|
|
172
179
|
INSTANCE_CREDENTIAL_ENCRYPTION_KEY: encryptionKey,
|
|
173
180
|
...extraSecrets,
|
|
181
|
+
...(context.functionalCapabilities?.["core.secrets"]?.enabled ? {
|
|
182
|
+
OON_SECRETS_INSTANCE: context.secretsScope.instance,
|
|
183
|
+
OON_SECRETS_TENANT: context.secretsScope.tenantId,
|
|
184
|
+
} : {}),
|
|
174
185
|
};
|
|
175
186
|
const args = ["-n", context.namespace, "create", "secret", "generic", "oon-runtime"];
|
|
176
187
|
for (const [key, value] of Object.entries(literals)) args.push(`--from-literal=${key}=${value}`);
|
|
@@ -244,6 +255,7 @@ async function provisionRuntime({ context, extraSecrets = {} }) {
|
|
|
244
255
|
"oon-runtime",
|
|
245
256
|
"INSTANCE_CREDENTIAL_ENCRYPTION_KEY",
|
|
246
257
|
) || randomSecret(48);
|
|
258
|
+
provisionSecrets(context);
|
|
247
259
|
applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
|
|
248
260
|
return { infra, databaseName, credentialRotated };
|
|
249
261
|
}
|
|
@@ -294,4 +306,4 @@ async function activateMember(context) {
|
|
|
294
306
|
return data;
|
|
295
307
|
}
|
|
296
308
|
|
|
297
|
-
module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
|
|
309
|
+
module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const crypto = require("node:crypto");
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const SECRET = "oon-core-secrets";
|
|
5
|
+
const MARKER = "oon-core-secrets-initialized";
|
|
6
|
+
function command(args, input) {
|
|
7
|
+
const result = spawnSync("kubectl", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
8
|
+
// Never surface kubectl output: admission errors may reflect the submitted object.
|
|
9
|
+
if (result.error || result.status !== 0) throw Error("SECRETS_PROVISIONING_FAILED: acesso ao cluster recusado ou indisponível.");
|
|
10
|
+
return result.stdout;
|
|
11
|
+
}
|
|
12
|
+
function provisionSecrets(context, run = command) {
|
|
13
|
+
if (!context.functionalCapabilities?.["core.secrets"]?.enabled) return;
|
|
14
|
+
if (!context.secretsScope?.instance || !context.secretsScope?.tenantId || !context.environment) throw Error("SECRETS_SCOPE_REQUIRED");
|
|
15
|
+
const read = (kind, name) => {
|
|
16
|
+
const output = run(["-n", context.namespace, "get", kind, name, "--ignore-not-found", "-o", "json"]);
|
|
17
|
+
try { return output?.trim() ? JSON.parse(output) : null; } catch { throw Error("SECRETS_CLUSTER_RESPONSE_INVALID"); }
|
|
18
|
+
};
|
|
19
|
+
const binding = JSON.stringify([context.appCode, context.secretsScope.instance, context.environment, context.secretsScope.tenantId]);
|
|
20
|
+
let secret = read("secret", SECRET);
|
|
21
|
+
const marker = read("secret", MARKER);
|
|
22
|
+
if (marker && Buffer.from(marker.data?.binding || "", "base64").toString("utf8") !== binding) throw Error("SECRETS_SCOPE_MISMATCH");
|
|
23
|
+
if (!secret) {
|
|
24
|
+
if (marker) throw Error("SECRETS_KEY_MISSING: restabeleça a chave por procedimento controlado.");
|
|
25
|
+
const document = { apiVersion: "v1", kind: "Secret", immutable: true, metadata: { name: SECRET, namespace: context.namespace }, type: "Opaque", stringData: { OON_SECRETS_KEY: crypto.randomBytes(32).toString("base64"), OON_SECRETS_BINDING: binding } };
|
|
26
|
+
// Create, never apply/upsert. A concurrent creator's value wins atomically.
|
|
27
|
+
try { run(["create", "-f", "-"], JSON.stringify(document)); }
|
|
28
|
+
catch { /* A failed create is only recoverable if a valid winner can be read. */ }
|
|
29
|
+
secret = read("secret", SECRET);
|
|
30
|
+
}
|
|
31
|
+
const decode = key => Buffer.from(secret?.data?.[key] || "", "base64").toString("utf8");
|
|
32
|
+
const keyValue = decode("OON_SECRETS_KEY");
|
|
33
|
+
if (!secret || secret.immutable !== true || decode("OON_SECRETS_BINDING") !== binding || !/^[A-Za-z0-9+/]{43}=$/.test(keyValue) || Buffer.from(keyValue, "base64").toString("base64") !== keyValue) throw Error("SECRETS_KEY_INVALID: provisionamento requer intervenção.");
|
|
34
|
+
if (!marker) {
|
|
35
|
+
const doc = { apiVersion: "v1", kind: "Secret", immutable: true, metadata: { name: MARKER, namespace: context.namespace }, type: "Opaque", stringData: { binding } };
|
|
36
|
+
try { run(["create", "-f", "-"], JSON.stringify(doc)); }
|
|
37
|
+
catch { const winner = read("secret", MARKER); if (Buffer.from(winner?.data?.binding || "", "base64").toString("utf8") !== binding) throw Error("SECRETS_MARKER_UNAVAILABLE"); }
|
|
38
|
+
}
|
|
39
|
+
// Never return key material to the executor/context/evidence serializer.
|
|
40
|
+
}
|
|
41
|
+
module.exports = { provisionSecrets };
|
|
@@ -147,6 +147,10 @@ function validateCapabilitySettings(settings, capabilities, issues) {
|
|
|
147
147
|
addIssue(issues, base, "exige a mesma capability declarada em capabilities.");
|
|
148
148
|
continue;
|
|
149
149
|
}
|
|
150
|
+
if (capability === "core.secrets") {
|
|
151
|
+
if (!isPlainObject(config) || config.enabled !== true || Object.keys(config).some(key => key !== "enabled")) addIssue(issues, base, "use apenas { enabled: true }.");
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
150
154
|
if (capability !== TRANSACTIONAL_EMAIL_CAPABILITY) {
|
|
151
155
|
addIssue(issues, base, "capability configurável desconhecida.");
|
|
152
156
|
continue;
|
|
@@ -286,6 +290,10 @@ function validateAppManifest(input, { coreVersion = corePackage.version } = {})
|
|
|
286
290
|
});
|
|
287
291
|
}
|
|
288
292
|
validateCapabilitySettings(input.capabilitySettings, input.capabilities, issues);
|
|
293
|
+
if (input.capabilities?.includes("core.secrets")) {
|
|
294
|
+
if (dimensions.tenancyModel !== "single_tenant" || dimensions.deploymentMode !== "dedicated") addIssue(issues, "capabilities", "core.secrets V1 exige single_tenant dedicado.");
|
|
295
|
+
if (input.capabilitySettings?.["core.secrets"]?.enabled !== true) addIssue(issues, "capabilitySettings.core.secrets", "declare { enabled: true } para provisionamento.");
|
|
296
|
+
}
|
|
289
297
|
validateCompatibility(input.compatibility, issues, coreVersion);
|
|
290
298
|
validateActivation(input.activation, issues);
|
|
291
299
|
if (input.auth !== undefined) {
|
|
@@ -366,7 +366,8 @@ async function assertAtomicInvariants(entry, candidate, current, session) {
|
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
const match = {
|
|
369
|
-
|
|
369
|
+
// Aggregation does not apply Mongoose query casting to HTTP string IDs.
|
|
370
|
+
[rule.parentLocalField]: entry.mongooseModel.schema?.path(rule.parentLocalField)?.cast(parentId) ?? parentId,
|
|
370
371
|
...compileMongoMatch(rule.match),
|
|
371
372
|
};
|
|
372
373
|
if (current?._id) match._id = { $ne: current._id };
|
|
@@ -514,6 +515,9 @@ async function recalculateTarget(entry, selectors, options = {}) {
|
|
|
514
515
|
updateOne: {
|
|
515
516
|
filter: { _id: documents[index]._id },
|
|
516
517
|
update: { $set: changed },
|
|
518
|
+
// Only server-derived, validated changes reach this recalculation path.
|
|
519
|
+
// readonly still rejects client writes in applyDomainMutation.
|
|
520
|
+
overwriteImmutable: true,
|
|
517
521
|
},
|
|
518
522
|
});
|
|
519
523
|
}
|