@oondemand/oon-core-back 0.7.3 → 0.7.6
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 +6 -0
- package/package.json +1 -1
- 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/delivery/runtime.js +15 -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,7 @@ 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.
|
package/package.json
CHANGED
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
|
|
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} }
|
|
@@ -164,11 +171,17 @@ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {
|
|
|
164
171
|
NODE_ENV: "production",
|
|
165
172
|
APP_ENVIRONMENT: context.environment,
|
|
166
173
|
APP_CODE: context.appCode,
|
|
174
|
+
...(context.commitSha ? { OON_BUILD_COMMIT: context.commitSha } : {}),
|
|
175
|
+
OON_RELEASE_ID: context.releaseId,
|
|
167
176
|
PUBLIC_APP_URL: context.publicUrl,
|
|
168
177
|
CENTRAL_ATIVACAO_URL: context.activationApiUrl,
|
|
169
178
|
CENTRAL_ATIVACAO_API_URL: context.activationApiUrl,
|
|
170
179
|
INSTANCE_CREDENTIAL_ENCRYPTION_KEY: encryptionKey,
|
|
171
180
|
...extraSecrets,
|
|
181
|
+
...(context.functionalCapabilities?.["core.secrets"]?.enabled ? {
|
|
182
|
+
OON_SECRETS_INSTANCE: context.secretsScope.instance,
|
|
183
|
+
OON_SECRETS_TENANT: context.secretsScope.tenantId,
|
|
184
|
+
} : {}),
|
|
172
185
|
};
|
|
173
186
|
const args = ["-n", context.namespace, "create", "secret", "generic", "oon-runtime"];
|
|
174
187
|
for (const [key, value] of Object.entries(literals)) args.push(`--from-literal=${key}=${value}`);
|
|
@@ -242,6 +255,7 @@ async function provisionRuntime({ context, extraSecrets = {} }) {
|
|
|
242
255
|
"oon-runtime",
|
|
243
256
|
"INSTANCE_CREDENTIAL_ENCRYPTION_KEY",
|
|
244
257
|
) || randomSecret(48);
|
|
258
|
+
provisionSecrets(context);
|
|
245
259
|
applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
|
|
246
260
|
return { infra, databaseName, credentialRotated };
|
|
247
261
|
}
|
|
@@ -292,4 +306,4 @@ async function activateMember(context) {
|
|
|
292
306
|
return data;
|
|
293
307
|
}
|
|
294
308
|
|
|
295
|
-
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
|
}
|