biz-a-cli 2.3.80-15333 → 2.3.80-15339
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/bin/hub.js +65 -32
- package/bin/hubEvent.js +38 -7
- package/engine/domain/applicationConfig.js +147 -4
- package/engine/domain/entityConfigLoader.js +28 -0
- package/engine/domain/entityConfigPrimer.js +130 -4
- package/engine/orm/rlsPolicy.js +37 -4
- package/engine/orm/tenantApi.js +20 -2
- package/engine/orm/userGroups.js +62 -0
- package/package.json +1 -1
- package/scheduler/datalib.js +47 -0
- package/security/internalPrincipal.js +33 -0
- package/worker/cliScriptWorker.js +33 -1
- package/worker/cliWorkerPool.js +10 -1
package/bin/hub.js
CHANGED
|
@@ -193,9 +193,12 @@ import { createFinaRouter } from "../engine/orm/finaEndpoint.js";
|
|
|
193
193
|
import { createSessionStore } from "../engine/orm/sessionStore.js";
|
|
194
194
|
import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
|
|
195
195
|
import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
|
|
196
|
-
import {
|
|
196
|
+
import { isTenancyDeclared } from "../engine/orm/entityConfig.js";
|
|
197
|
+
import {
|
|
198
|
+
ensureEntityConfig,
|
|
199
|
+
setEntityConfigRefreshHook,
|
|
200
|
+
} from "../engine/domain/entityConfigPrimer.js";
|
|
197
201
|
import { createTenantContextApplier, APP_ROLE } from "../engine/orm/rlsApplier.js";
|
|
198
|
-
import { createDomainConfigLoader } from "../engine/domain/entityConfigLoader.js";
|
|
199
202
|
import {
|
|
200
203
|
execPostgres,
|
|
201
204
|
execPostgresStatements,
|
|
@@ -263,6 +266,19 @@ if (hubSessionStore) {
|
|
|
263
266
|
setInterval(sweepSessions, SESSION_SWEEP_INTERVAL_MS).unref();
|
|
264
267
|
}
|
|
265
268
|
|
|
269
|
+
/*
|
|
270
|
+
* Doc 6 Q5 — keep this thread's declarations current on the request path.
|
|
271
|
+
*
|
|
272
|
+
* ⚠️ The check is TTL'd inside `ensureEntityConfig`, so the overwhelming majority of requests pay
|
|
273
|
+
* nothing; roughly once every few seconds one request awaits a single digest query.
|
|
274
|
+
*
|
|
275
|
+
* ⚠️ `.finally(next)`, never `.then(next)`: a database hiccup while checking freshness must not stop
|
|
276
|
+
* the request. The declarations this thread already holds are the ones it would have used anyway.
|
|
277
|
+
*/
|
|
278
|
+
app.use("/fina", (_req, _res, next) => {
|
|
279
|
+
ensureEntityConfig(Number(argv.dbindex)).finally(() => next());
|
|
280
|
+
});
|
|
281
|
+
|
|
266
282
|
app.use(
|
|
267
283
|
"/fina",
|
|
268
284
|
createFinaRouter({
|
|
@@ -415,40 +431,57 @@ server.listen(argv.serverport, () => {
|
|
|
415
431
|
* ⚠️ Deliberately NOT awaited and never fatal. It fails soft to "every entity is hard" — exactly what
|
|
416
432
|
* the ORM did before §6.6 existed — so a config problem must not delay or prevent the hub coming up.
|
|
417
433
|
*/
|
|
434
|
+
/*
|
|
435
|
+
* Doc 3 §11 layer 2 — install the RLS context applier, but ONLY once an application has actually
|
|
436
|
+
* declared tenancy. Extracted and made IDEMPOTENT for Doc 6 Q5: declarations can now change while
|
|
437
|
+
* the hub is running, so this runs after the first prime AND after any refresh that moved them.
|
|
438
|
+
*
|
|
439
|
+
* ⚠️ IT NEVER UNINSTALLS. Un-declaring tenancy is rare, and dropping the applier mid-session would
|
|
440
|
+
* take RLS enforcement off in-flight requests — strictly worse than carrying a cost nobody asked for
|
|
441
|
+
* until the next restart.
|
|
442
|
+
*/
|
|
443
|
+
let tenantApplierInstalled = false;
|
|
444
|
+
const installTenantApplierIfDeclared = () => {
|
|
445
|
+
if (tenantApplierInstalled) return;
|
|
446
|
+
if (!isTenancyDeclared(Number(argv.dbindex))) return;
|
|
447
|
+
setTenantContextApplier(createTenantContextApplier());
|
|
448
|
+
tenantApplierInstalled = true;
|
|
449
|
+
logger.info(
|
|
450
|
+
`[RLS] tenant context applier installed for db ${argv.dbindex}; ` +
|
|
451
|
+
"request-path queries now run as " + APP_ROLE + " inside a transaction",
|
|
452
|
+
);
|
|
453
|
+
};
|
|
454
|
+
|
|
418
455
|
if (isPostgresIndex(Number(argv.dbindex))) {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
`[RLS] tenant context applier installed for db ${argv.dbindex}; ` +
|
|
445
|
-
"request-path queries now run as " + APP_ROLE + " inside a transaction",
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
})
|
|
456
|
+
/*
|
|
457
|
+
* Doc 6 Q5 — a publish that changes declarations must reach this thread without a restart, and
|
|
458
|
+
* the applier has to be installed if that publish is the one that DECLARES tenancy. Registering
|
|
459
|
+
* the hook before the first prime means even the startup read cannot outrun it.
|
|
460
|
+
*/
|
|
461
|
+
setEntityConfigRefreshHook(() => installTenantApplierIfDeclared());
|
|
462
|
+
|
|
463
|
+
/*
|
|
464
|
+
* ⚠️ `ensureEntityConfig`, not `primeEntityConfig` directly: it is the same read, but it records
|
|
465
|
+
* this thread's fingerprint so the refresh check has a baseline. Priming behind its back would
|
|
466
|
+
* leave the first request to re-read the whole config for nothing.
|
|
467
|
+
*/
|
|
468
|
+
ensureEntityConfig(Number(argv.dbindex))
|
|
469
|
+
/*
|
|
470
|
+
* ⚠️ INSTALLING IT IS NOT FREE AND NOT INVISIBLE. `execPostgres` opens a transaction only
|
|
471
|
+
* when an applier is present; until then every read and every single-statement write is a
|
|
472
|
+
* bare `pool.query`. Installing it makes each one check out a pooled connection for a
|
|
473
|
+
* BEGIN/COMMIT. Gating on the declaration keeps that cost — and the whole SET LOCAL ROLE
|
|
474
|
+
* machinery — off every tenant that has not asked for it.
|
|
475
|
+
*
|
|
476
|
+
* ⚠️ ORDER: the applier must be installed BEFORE any policy exists. A policy whose
|
|
477
|
+
* predicate reads `app.current_tenant` while nothing sets it hides every row from
|
|
478
|
+
* everyone, which on a live POS is an outage rather than a security improvement.
|
|
479
|
+
*/
|
|
480
|
+
.then(() => installTenantApplierIfDeclared())
|
|
449
481
|
.catch(() => {});
|
|
450
482
|
}
|
|
451
483
|
|
|
484
|
+
|
|
452
485
|
if (argv.mode.trim().toLowerCase() === "agent") {
|
|
453
486
|
// Message broker agent not need connection to server, hubServer, directHub
|
|
454
487
|
logger.info(`[Message Broker] Starting as isolated CLI Agent...`);
|
package/bin/hubEvent.js
CHANGED
|
@@ -17,12 +17,13 @@ import { makeExtReqHandler } from "../engine/ext/hubProxy.js";
|
|
|
17
17
|
|
|
18
18
|
import { logger } from "../logger.js";
|
|
19
19
|
import { routeApiRequest } from "../engine/orm/apiRoute.js";
|
|
20
|
+
import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
|
|
20
21
|
import { runAsPrincipal, runTrusted, ACTIVE_TENANT_NOT_HELD } from "../engine/orm/tenantContext.js";
|
|
21
22
|
import { resolveRequestScope, SESSION_NOT_USABLE } from "../engine/orm/requestScope.js";
|
|
22
23
|
import { createSessionStore } from "../engine/orm/sessionStore.js";
|
|
23
24
|
import { resolveUserTenancy } from "../engine/orm/tenantResolution.js";
|
|
24
25
|
import { buildUserTenantSql } from "../engine/orm/finaAuth.js";
|
|
25
|
-
import { authorizeRequest } from "../security/internalPrincipal.js";
|
|
26
|
+
import { authorizeRequest, principalForCliScript } from "../security/internalPrincipal.js";
|
|
26
27
|
import {
|
|
27
28
|
execPostgres,
|
|
28
29
|
execPostgresStatements,
|
|
@@ -477,6 +478,22 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
477
478
|
query: { scriptName: data.scriptName },
|
|
478
479
|
headers: { "user-agent": "biz-a-client-socket" },
|
|
479
480
|
};
|
|
481
|
+
/*
|
|
482
|
+
* ⚠️⚠️ Doc 6 §8.3 — WHO IS ASKING. Until now this entrance carried no verified identity
|
|
483
|
+
* at all, which is why a CLI script could not gate anything on a right. The client sends
|
|
484
|
+
* the same signed principal it already attaches to HTTP; everything the script sees comes
|
|
485
|
+
* from inside that signature.
|
|
486
|
+
*
|
|
487
|
+
* ⚠️ OBSERVE-SHAPED, deliberately. An absent or invalid principal yields null and the script
|
|
488
|
+
* still RUNS — it is the script's job to refuse when it needs an identity it did not get.
|
|
489
|
+
* Refusing here would lock out every client that has not been rebuilt, which is exactly
|
|
490
|
+
* what `--principalMode` exists to avoid.
|
|
491
|
+
*
|
|
492
|
+
* ⚠️ `data.headers`, never `data.scriptData`: the payload around this is caller-shaped.
|
|
493
|
+
*/
|
|
494
|
+
const cliScriptPrincipal = principalForCliScript(data.headers, {
|
|
495
|
+
mode: argv["principalMode"] || "observe",
|
|
496
|
+
});
|
|
480
497
|
cb(
|
|
481
498
|
null,
|
|
482
499
|
await execCLIScriptWorker(
|
|
@@ -487,6 +504,7 @@ const handleCliCommand = async (data, cb, argv) => {
|
|
|
487
504
|
},
|
|
488
505
|
data.scriptName,
|
|
489
506
|
liveTriggerPayload,
|
|
507
|
+
cliScriptPrincipal,
|
|
490
508
|
),
|
|
491
509
|
);
|
|
492
510
|
break;
|
|
@@ -735,12 +753,25 @@ export const clientListener = (socket, argv, via = "?") => {
|
|
|
735
753
|
against the SIGNED claim, so it can only select among tenants already granted. */
|
|
736
754
|
/* Doc 4 §9.5 / B2 — the session ROW is the authority for tenancy. A principal minted
|
|
737
755
|
before sessions existed has no id and falls back to its own deprecated claims. */
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
756
|
+
/*
|
|
757
|
+
* Doc 6 Q5 — this is the OTHER entrance to the same ORM. `bin/hub.js` keeps the HTTP
|
|
758
|
+
* path's declarations current with a `/fina` middleware; the browser's data calls
|
|
759
|
+
* arrive here instead, so the same TTL'd check belongs in front of them or half the
|
|
760
|
+
* request traffic would keep reading whatever this thread learned at startup.
|
|
761
|
+
*
|
|
762
|
+
* ⚠️ Chained, never awaited-and-thrown: a freshness check that fails must not refuse a
|
|
763
|
+
* request the existing declarations could answer perfectly well.
|
|
764
|
+
*/
|
|
765
|
+
ensureEntityConfig(Number(argv["dbindex"]))
|
|
766
|
+
.catch(() => {})
|
|
767
|
+
.then(() =>
|
|
768
|
+
resolveRequestScope({
|
|
769
|
+
claims: decision.claims,
|
|
770
|
+
headers: reqData.headers,
|
|
771
|
+
sessionStore: sessionStoreFor(Number(argv["dbindex"])),
|
|
772
|
+
resolveTenancy: tenancyResolverFor(Number(argv["dbindex"])),
|
|
773
|
+
}),
|
|
774
|
+
)
|
|
744
775
|
.then((scope) =>
|
|
745
776
|
/* ⚠️ The ROUTER is handed the resolved scope too — see finaEndpoint for why. */
|
|
746
777
|
runAsPrincipalOr403(scope, () =>
|
|
@@ -378,11 +378,98 @@ export const buildTemplateDomainConfigResolverSnippet = () => {
|
|
|
378
378
|
this._ensureDomainConfig = ensureDomainConfig;`;
|
|
379
379
|
};
|
|
380
380
|
|
|
381
|
+
/*
|
|
382
|
+
* ⚠️⚠️⚠️ THE GENERATED TEMPLATE MUST DECLARE ITS LIBRARIES.
|
|
383
|
+
*
|
|
384
|
+
* App + domain config publishing used to run in the CLIENT; moving it here dropped this step. The
|
|
385
|
+
* generated form then carried only beforeLoadData / afterLoadData / onInit / onDestroy / onPipe, so
|
|
386
|
+
* `form.component.ts` calling `doFunction('useLibrary')` got undefined and loaded nothing, and
|
|
387
|
+
* `db.service.loadScript` — which arms the template reloader from that SAME list — watched no
|
|
388
|
+
* libraries at all. A redeployed library was then never picked up by an open session, and nothing
|
|
389
|
+
* said so. See Doc 6 §7.12.
|
|
390
|
+
*
|
|
391
|
+
* ⚠️ THIS MUST MIRROR THE CLIENT EXACTLY — `resolveDomainLibraries` (render.domain.ts) and
|
|
392
|
+
* `normalizeDomainLibraryModules` (domain.service.ts). Any divergence means the form loads one set
|
|
393
|
+
* and the reloader watches another, which is a silent bug of precisely the kind this replaces.
|
|
394
|
+
*
|
|
395
|
+
* A module id is `<appName>-<fileName>`, e.g. `hrlib-hrpublishlib`: the app bundle that owns the
|
|
396
|
+
* file, then the file's own basename. A handler string carries both in its first two segments.
|
|
397
|
+
*/
|
|
398
|
+
export const resolveDomainLibraryModules = (parsedDomainConfig) => {
|
|
399
|
+
const config = parsedDomainConfig && typeof parsedDomainConfig === "object" ? parsedDomainConfig : {};
|
|
400
|
+
|
|
401
|
+
const rawLibraries = Array.isArray(config?.libraries)
|
|
402
|
+
? config.libraries
|
|
403
|
+
: Array.isArray(config?.ui?.libraries)
|
|
404
|
+
? config.ui.libraries
|
|
405
|
+
: [];
|
|
406
|
+
const configuredModules = [
|
|
407
|
+
...new Set(
|
|
408
|
+
rawLibraries
|
|
409
|
+
.map((item) => {
|
|
410
|
+
if (typeof item === "string") {
|
|
411
|
+
return item.trim();
|
|
412
|
+
}
|
|
413
|
+
if (item && typeof item === "object") {
|
|
414
|
+
if (typeof item.domain === "string") {
|
|
415
|
+
return item.domain.trim();
|
|
416
|
+
}
|
|
417
|
+
if (typeof item.module === "string") {
|
|
418
|
+
return item.module.trim();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return "";
|
|
422
|
+
})
|
|
423
|
+
.filter((moduleName) => moduleName.length > 0),
|
|
424
|
+
),
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const events =
|
|
428
|
+
config?.events && typeof config.events === "object" && !Array.isArray(config.events)
|
|
429
|
+
? config.events
|
|
430
|
+
: config?.ui?.events && typeof config.ui.events === "object" && !Array.isArray(config.ui.events)
|
|
431
|
+
? config.ui.events
|
|
432
|
+
: {};
|
|
433
|
+
const derivedModules = new Set();
|
|
434
|
+
for (const eventDef of Object.values(events)) {
|
|
435
|
+
const handler = typeof eventDef?.handler === "string" ? eventDef.handler.trim() : "";
|
|
436
|
+
if (!handler) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const segments = handler
|
|
440
|
+
.split(".")
|
|
441
|
+
.map((segment) => String(segment ?? "").trim())
|
|
442
|
+
.filter((segment) => segment.length > 0);
|
|
443
|
+
/* Fewer than 3 segments names a function in the template itself, not a library. */
|
|
444
|
+
if (segments.length >= 3) {
|
|
445
|
+
derivedModules.add(`${segments[0]}-${segments[1]}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (configuredModules.length === 0) {
|
|
450
|
+
return [...derivedModules];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* A bare domain name (`hrlib`) expands to the `hrlib-*` its handlers actually reference; if none
|
|
454
|
+
do, it is kept as-is so an explicitly declared library is never silently dropped. */
|
|
455
|
+
const derivedModuleList = [...derivedModules];
|
|
456
|
+
const normalizedConfigured = configuredModules.flatMap((moduleName) => {
|
|
457
|
+
if (moduleName.includes("-")) {
|
|
458
|
+
return [moduleName];
|
|
459
|
+
}
|
|
460
|
+
const matchedDerived = derivedModuleList.filter((candidate) => candidate.startsWith(`${moduleName}-`));
|
|
461
|
+
return matchedDerived.length > 0 ? matchedDerived : [moduleName];
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
return [...new Set([...normalizedConfigured, ...derivedModuleList])];
|
|
465
|
+
};
|
|
466
|
+
|
|
381
467
|
export const buildHardcodedTemplateContent = (
|
|
382
468
|
entryPointName = "loginForm",
|
|
383
469
|
domainKey = null,
|
|
384
470
|
templateType = "form",
|
|
385
471
|
domainSource = null,
|
|
472
|
+
libraryModules = [],
|
|
386
473
|
) => {
|
|
387
474
|
const sharedDomainConfigResolverScript = buildTemplateDomainConfigResolverSnippet();
|
|
388
475
|
const formTemplateContent = `get = function () {
|
|
@@ -390,7 +477,7 @@ export const buildHardcodedTemplateContent = (
|
|
|
390
477
|
model: {},
|
|
391
478
|
tableName: "",
|
|
392
479
|
fields: [],
|
|
393
|
-
functions: {
|
|
480
|
+
functions: {__USE_LIBRARY_BLOCK__
|
|
394
481
|
beforeLoadData: function (id) {
|
|
395
482
|
${sharedDomainConfigResolverScript}
|
|
396
483
|
return new Promise((resolve) => {
|
|
@@ -450,7 +537,7 @@ ${sharedDomainConfigResolverScript}
|
|
|
450
537
|
filter: [],
|
|
451
538
|
buttons: [],
|
|
452
539
|
distinct: false,
|
|
453
|
-
functions: {
|
|
540
|
+
functions: {__USE_LIBRARY_BLOCK__
|
|
454
541
|
beforeLoadData: function () {
|
|
455
542
|
${sharedDomainConfigResolverScript}
|
|
456
543
|
return new Promise((resolve) => {
|
|
@@ -543,7 +630,20 @@ ${sharedDomainConfigResolverScript}
|
|
|
543
630
|
const safeDomainKey = typeof domainKey === "string" && domainKey.trim().length > 0 ? domainKey.trim() : "";
|
|
544
631
|
const safeDomainSource = typeof domainSource === "string" && domainSource.trim().length > 0 ? domainSource.trim() : "";
|
|
545
632
|
const domainResolverExpression = `(() => { const domains = appConfig && appConfig.domains && typeof appConfig.domains === 'object' ? appConfig.domains : null; if (!domains) { return null; } const preferredDomainKey = ${JSON.stringify(safeDomainKey)}; if (preferredDomainKey && domains[preferredDomainKey]) { return domains[preferredDomainKey]; } if (domains.salesOrderDomain) { return domains.salesOrderDomain; } const domainKeys = Object.keys(domains); return domainKeys.length > 0 ? domains[domainKeys[0]] : null; })()`;
|
|
633
|
+
/*
|
|
634
|
+
* ⚠️ Emitted ONLY when there is something to declare, so a library-less domain publishes exactly
|
|
635
|
+
* the template it always did. The list is inlined as JSON rather than computed at runtime: the
|
|
636
|
+
* template is read back by `db.service.loadScript` with `JSON.parse`, which then calls this body
|
|
637
|
+
* through `new Function` — it has no access to the domain config.
|
|
638
|
+
*/
|
|
639
|
+
const safeLibraryModules = Array.isArray(libraryModules)
|
|
640
|
+
? libraryModules.filter((moduleName) => typeof moduleName === "string" && moduleName.trim().length > 0)
|
|
641
|
+
: [];
|
|
642
|
+
const useLibraryBlock =
|
|
643
|
+
safeLibraryModules.length > 0 ? `\n\t\t\tuseLibrary: function () { return ${JSON.stringify(safeLibraryModules)}; },` : "";
|
|
644
|
+
|
|
546
645
|
const resolvedTemplateContent = templateContent
|
|
646
|
+
.replace(/__USE_LIBRARY_BLOCK__/g, useLibraryBlock)
|
|
547
647
|
.replace(/"__ENTRY_POINT_NAME__"/g, JSON.stringify(safeEntryPointName))
|
|
548
648
|
.replace(/"__DOMAIN_SOURCE__"/g, JSON.stringify(safeDomainSource))
|
|
549
649
|
.replace(
|
|
@@ -582,6 +682,11 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
|
|
|
582
682
|
(name) => String(name ?? "").trim().length > 0,
|
|
583
683
|
),
|
|
584
684
|
);
|
|
685
|
+
/* ⚠️ Derived ONCE per domain, from the domain config this call is publishing — every template it
|
|
686
|
+
generates belongs to that domain, so they all declare the same library set. See
|
|
687
|
+
resolveDomainLibraryModules for why the generated template has to declare it at all. */
|
|
688
|
+
const libraryModules = resolveDomainLibraryModules(safeDomainConfig);
|
|
689
|
+
|
|
585
690
|
let unresolvedTargetIndex = 0;
|
|
586
691
|
const templateFiles = templateTargets.map((target, targetIndex) => {
|
|
587
692
|
const explicitDomainReference = String(target.domainKey ?? "").trim();
|
|
@@ -612,7 +717,27 @@ export const buildPublishBodyFromConfigs = (parsedApplicationConfig, parsedDomai
|
|
|
612
717
|
resolvedDomainKey && domainMap && typeof domainMap === "object" ? String(domainMap?.[resolvedDomainKey]?.source ?? "").trim() : "";
|
|
613
718
|
return {
|
|
614
719
|
name: target.fileName,
|
|
615
|
-
|
|
720
|
+
/*
|
|
721
|
+
* ⚠️⚠️ DID THIS DOMAIN ACTUALLY DECLARE THE ENTRY POINT, or did we guess an owner for it?
|
|
722
|
+
*
|
|
723
|
+
* The guess above walks the other domain keys POSITIONALLY, which is a coin toss once more
|
|
724
|
+
* than two domains are registered. `publishApplicationConfig` merges the passes first-wins,
|
|
725
|
+
* so whichever domain is scanned first imposed its guess on every template name — and a
|
|
726
|
+
* template naming the wrong domain makes the client throw E_ENTRYPOINT_NOT_FOUND, because
|
|
727
|
+
* that domain declares no such entry point. Live on db 3: Order-In and several other
|
|
728
|
+
* screens, owned by `supertailruntime`, were handed to `hr`.
|
|
729
|
+
*
|
|
730
|
+
* Every domain IS scanned, so the one that declares it claims it on its own pass. This flag
|
|
731
|
+
* is what lets the merge prefer that claim over a guess.
|
|
732
|
+
*/
|
|
733
|
+
claimed: configuredEntryPointNames.has(target.entryPointName) && resolvedDomainKey === configuredDomainKey,
|
|
734
|
+
content: buildHardcodedTemplateContent(
|
|
735
|
+
target.entryPointName,
|
|
736
|
+
resolvedDomainKey,
|
|
737
|
+
target.templateType ?? "form",
|
|
738
|
+
resolvedDomainSource,
|
|
739
|
+
libraryModules,
|
|
740
|
+
),
|
|
616
741
|
};
|
|
617
742
|
});
|
|
618
743
|
const fileList = [{ name: "menu.json", content: menuContent }, ...templateFiles];
|
|
@@ -663,9 +788,23 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
|
|
|
663
788
|
/* Parsed once in the collect pass, reused in the build pass. */
|
|
664
789
|
const scannedDomains = [];
|
|
665
790
|
const seenFileNames = new Set();
|
|
791
|
+
/*
|
|
792
|
+
* ⚠️ FIRST-WINS, EXCEPT THAT A CLAIM BEATS A GUESS. A domain that declares the entry point knows
|
|
793
|
+
* it owns the template; a domain that merely got handed it by the positional fallback does not.
|
|
794
|
+
* Without this, the domain scanned FIRST decided every template's owner and the screens it does
|
|
795
|
+
* not declare answered E_ENTRYPOINT_NOT_FOUND in the browser.
|
|
796
|
+
*/
|
|
666
797
|
const addFilesToMergedList = (fileList) => {
|
|
667
798
|
for (const file of fileList) {
|
|
668
799
|
if (seenFileNames.has(file.name)) {
|
|
800
|
+
if (!file.claimed) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const existingIndex = mergedFileList.findIndex((entry) => entry.name === file.name);
|
|
804
|
+
if (existingIndex < 0 || mergedFileList[existingIndex].claimed) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
mergedFileList[existingIndex] = file;
|
|
669
808
|
continue;
|
|
670
809
|
}
|
|
671
810
|
seenFileNames.add(file.name);
|
|
@@ -735,7 +874,11 @@ export const publishApplicationConfig = async (payload = {}, argv = {}) => {
|
|
|
735
874
|
}
|
|
736
875
|
}
|
|
737
876
|
|
|
738
|
-
|
|
877
|
+
/* `claimed` is merge bookkeeping, not part of the bundle contract — strip it before it ships. */
|
|
878
|
+
const publishBody = {
|
|
879
|
+
verbose: false,
|
|
880
|
+
fileList: mergedFileList.map(({ name, content }) => ({ name, content })),
|
|
881
|
+
};
|
|
739
882
|
|
|
740
883
|
const response = await publishApp(
|
|
741
884
|
{
|
|
@@ -98,3 +98,31 @@ export const createDomainConfigLoader = ({ exec, dbIndex }) => async () => {
|
|
|
98
98
|
}
|
|
99
99
|
return parsed;
|
|
100
100
|
};
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* ⚠️⚠️ Doc 6 Q5 — A CHEAP FINGERPRINT OF EVERYTHING THE DECLARATIONS ARE BUILT FROM.
|
|
104
|
+
*
|
|
105
|
+
* The entity-config cache is per THREAD and was read exactly once per thread, so a publish reached a
|
|
106
|
+
* running hub only by restarting it. `ensureEntityConfig` re-checks this fingerprint on a timer and
|
|
107
|
+
* re-primes when it moves.
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ NOT `max(id)`. `sys$config` is UPDATED IN PLACE when name+version match, so republishing a
|
|
110
|
+
* domain at the SAME version — the commonest thing anyone does while iterating — leaves the highest
|
|
111
|
+
* id untouched. Verified on db 3: 12 rows, max id 14, `supertailsetup` present twice, one row per
|
|
112
|
+
* version. Only the content answers honestly.
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ The hashing happens IN POSTGRES and only the 32-char digest crosses the wire; the configs
|
|
115
|
+
* themselves are hundreds of KB and must not be shipped every few seconds just to be compared.
|
|
116
|
+
*
|
|
117
|
+
* Returns null when the table cannot be read, which the caller treats as "do not change anything".
|
|
118
|
+
*/
|
|
119
|
+
export const readConfigGeneration = async ({ exec, dbIndex }) => {
|
|
120
|
+
const rows = await exec(
|
|
121
|
+
`SELECT md5(string_agg(id || ':' || name || ':' || version || ':' || md5(coalesce(data, '')), '|' ORDER BY id)) AS generation FROM ${PG_CONFIG_TABLE}`,
|
|
122
|
+
dbIndex,
|
|
123
|
+
);
|
|
124
|
+
const generation = rows?.[0]?.generation ?? rows?.[0]?.GENERATION ?? null;
|
|
125
|
+
/* An empty sys$config aggregates to NULL; a stable sentinel keeps "empty" distinguishable from
|
|
126
|
+
"unreadable", which is the difference between leaving the cache alone and refreshing it. */
|
|
127
|
+
return generation == null ? "empty" : String(generation);
|
|
128
|
+
};
|
|
@@ -27,10 +27,37 @@
|
|
|
27
27
|
* `entityConfig.js`, and closing that cycle inside the ORM once left `createExecutor` uninitialised.
|
|
28
28
|
*/
|
|
29
29
|
import { primeEntityConfig } from "../orm/entityConfig.js";
|
|
30
|
-
import { createDomainConfigLoader } from "./entityConfigLoader.js";
|
|
30
|
+
import { createDomainConfigLoader, readConfigGeneration } from "./entityConfigLoader.js";
|
|
31
31
|
import { execPostgres, isPostgresIndex } from "./dialect.js";
|
|
32
32
|
import { logger } from "../../logger.js";
|
|
33
33
|
|
|
34
|
+
/*
|
|
35
|
+
* ⚠️⚠️ Doc 6 Q5 — HOW OFTEN A THREAD ASKS WHETHER THE DECLARATIONS MOVED.
|
|
36
|
+
*
|
|
37
|
+
* `resetEntityConfig` had no caller outside tests, so each thread read the declarations once and
|
|
38
|
+
* kept them: the hub's main thread until restart, a worker for the life of the thread. A publish
|
|
39
|
+
* that added or changed a declaration simply did not arrive, and the staleness is SILENT — the same
|
|
40
|
+
* five fallbacks described above, taken because the cache is OLD rather than empty. A newly
|
|
41
|
+
* soft-delete entity was PHYSICALLY deleted; a newly tenanted table was neither filtered nor stamped.
|
|
42
|
+
*
|
|
43
|
+
* The check is one small digest query, so this bounds staleness to seconds rather than to a restart.
|
|
44
|
+
* It is not zero-cost, which is why it is a TTL and not a per-request read.
|
|
45
|
+
*/
|
|
46
|
+
export const CONFIG_GENERATION_TTL_MS = 5000;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Called with the dbIndex after a refresh ACTUALLY changed the declarations.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ It exists for one specific reason. `bin/hub.js` installs the RLS context applier ONCE, right
|
|
52
|
+
* after the first prime, gated on `isTenancyDeclared`. An app that declares tenancy for the first
|
|
53
|
+
* time in a publish would refresh this cache and still have no applier — tenancy declared and NOT
|
|
54
|
+
* enforced, which is quieter and worse than the stale cache this fixes.
|
|
55
|
+
*/
|
|
56
|
+
let onRefresh = null;
|
|
57
|
+
export const setEntityConfigRefreshHook = (hook) => {
|
|
58
|
+
onRefresh = typeof hook === "function" ? hook : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
34
61
|
/*
|
|
35
62
|
* ⚠️ PROMISES, not booleans. A BPM agent runs effects continuously and they all enter through one
|
|
36
63
|
* `run`; several can be in flight inside a single worker at once. Recording "started" as a promise
|
|
@@ -51,8 +78,29 @@ const degrade = (dbIndex, reason) => {
|
|
|
51
78
|
);
|
|
52
79
|
};
|
|
53
80
|
|
|
81
|
+
/* dbIndex -> { generation, checkedAt } for the copy this thread currently holds. */
|
|
82
|
+
const freshness = new Map();
|
|
83
|
+
/* dbIndex -> in-flight freshness check, so concurrent callers share one digest query. */
|
|
84
|
+
const checking = new Map();
|
|
85
|
+
|
|
54
86
|
const primeOnce = async (dbIndex) => {
|
|
55
87
|
try {
|
|
88
|
+
/*
|
|
89
|
+
* ⚠️ THE FINGERPRINT IS READ BEFORE THE CONFIGS, NEVER AFTER. A publish landing between the
|
|
90
|
+
* two reads must cost an EXTRA refresh, never a missed one — taking it afterwards would
|
|
91
|
+
* record a fingerprint newer than the data just read, and that change would stay invisible
|
|
92
|
+
* until something else happened to publish.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ And it must not be fatal: a digest we cannot read is no reason to skip priming. Recording
|
|
95
|
+
* `null` simply means the next check has nothing to compare against and refreshes.
|
|
96
|
+
*/
|
|
97
|
+
let generation = null;
|
|
98
|
+
try {
|
|
99
|
+
generation = await readConfigGeneration({ exec: execPostgres, dbIndex });
|
|
100
|
+
} catch {
|
|
101
|
+
generation = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
const loadDomainConfigs = createDomainConfigLoader({
|
|
57
105
|
exec: execPostgres,
|
|
58
106
|
dbIndex,
|
|
@@ -63,7 +111,10 @@ const primeOnce = async (dbIndex) => {
|
|
|
63
111
|
* try/catch therefore treats a FAILED read as a successful one and memoises the failure
|
|
64
112
|
* forever, which is worse than not caching at all. The boolean is the only honest signal.
|
|
65
113
|
*/
|
|
66
|
-
if (await primeEntityConfig({ dbIndex, loadDomainConfigs }))
|
|
114
|
+
if (await primeEntityConfig({ dbIndex, loadDomainConfigs })) {
|
|
115
|
+
freshness.set(dbIndex, { generation, checkedAt: Date.now() });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
67
118
|
degrade(dbIndex, "declarations could not be read");
|
|
68
119
|
} catch (error) {
|
|
69
120
|
/*
|
|
@@ -75,6 +126,59 @@ const primeOnce = async (dbIndex) => {
|
|
|
75
126
|
}
|
|
76
127
|
};
|
|
77
128
|
|
|
129
|
+
/*
|
|
130
|
+
* Doc 6 Q5 — has anything been published since this thread last looked?
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ FAILS SOFT IN BOTH DIRECTIONS. A digest we cannot read leaves the declarations exactly as they
|
|
133
|
+
* are (blinding the thread is the state this module exists to end), and a re-prime that fails leaves
|
|
134
|
+
* the previous copy in place because `primeEntityConfig` only replaces the maps on success.
|
|
135
|
+
*/
|
|
136
|
+
const refreshIfStale = async (dbIndex) => {
|
|
137
|
+
const current = freshness.get(dbIndex);
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
if (current && now - current.checkedAt < CONFIG_GENERATION_TTL_MS) return;
|
|
140
|
+
|
|
141
|
+
let generation;
|
|
142
|
+
try {
|
|
143
|
+
generation = await readConfigGeneration({ exec: execPostgres, dbIndex });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
/* Checked, not read: wait out another TTL rather than hammering a database in trouble. */
|
|
146
|
+
if (current) {
|
|
147
|
+
freshness.set(dbIndex, { ...current, checkedAt: now });
|
|
148
|
+
}
|
|
149
|
+
logger.warn(
|
|
150
|
+
`[entityConfig] could not read the config fingerprint for db ${dbIndex} ` +
|
|
151
|
+
`(${error?.message ?? error}); keeping the declarations this thread already has.`,
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (current && generation === current.generation) {
|
|
157
|
+
freshness.set(dbIndex, { ...current, checkedAt: now });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const loadDomainConfigs = createDomainConfigLoader({ exec: execPostgres, dbIndex });
|
|
162
|
+
if (await primeEntityConfig({ dbIndex, loadDomainConfigs })) {
|
|
163
|
+
freshness.set(dbIndex, { generation, checkedAt: Date.now() });
|
|
164
|
+
logger.info(`[entityConfig] db ${dbIndex}: declarations changed since this thread last read them; refreshed.`);
|
|
165
|
+
if (onRefresh) {
|
|
166
|
+
/* ⚠️ A broken hook must not cost the refresh that already succeeded. */
|
|
167
|
+
try {
|
|
168
|
+
await onRefresh(dbIndex);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.warn(`[entityConfig] refresh hook failed for db ${dbIndex}: ${error?.message ?? error}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
/* Re-prime failed: the previous declarations stand, and the fingerprint is NOT recorded so the
|
|
176
|
+
next check tries again rather than treating the failure as the new truth. */
|
|
177
|
+
if (current) {
|
|
178
|
+
freshness.set(dbIndex, { ...current, checkedAt: now });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
78
182
|
/*
|
|
79
183
|
* Prime this thread for `dbIndex` if it has not been primed already. Safe to await on every entry to
|
|
80
184
|
* a worker: after the first call it resolves immediately.
|
|
@@ -88,10 +192,32 @@ export const ensureEntityConfig = async (dbIndex) => {
|
|
|
88
192
|
if (!Number.isFinite(index) || !isPostgresIndex(index)) return;
|
|
89
193
|
if (!priming.has(index)) {
|
|
90
194
|
priming.set(index, primeOnce(index));
|
|
195
|
+
await priming.get(index);
|
|
196
|
+
return;
|
|
91
197
|
}
|
|
92
198
|
await priming.get(index);
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* Doc 6 Q5 — already primed, so ask (at most once per TTL) whether it is still true.
|
|
202
|
+
*
|
|
203
|
+
* ⚠️ Held as a PROMISE for the same reason the priming above is: several effects run concurrently
|
|
204
|
+
* inside one worker and a whole console-load's worth of requests arrive together on the hub, so
|
|
205
|
+
* they share ONE digest query instead of each issuing their own.
|
|
206
|
+
*/
|
|
207
|
+
if (!checking.has(index)) {
|
|
208
|
+
checking.set(
|
|
209
|
+
index,
|
|
210
|
+
refreshIfStale(index).finally(() => checking.delete(index)),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
await checking.get(index);
|
|
93
214
|
};
|
|
94
215
|
|
|
95
216
|
/* Test hook. The cache it fronts has `resetEntityConfig`; this clears the "already started" record so
|
|
96
|
-
a test can prime again
|
|
97
|
-
|
|
217
|
+
a test can prime again — and the freshness record with it, or the next prime would be judged
|
|
218
|
+
against a fingerprint belonging to declarations that are no longer there. */
|
|
219
|
+
export const resetEntityConfigPrimer = () => {
|
|
220
|
+
priming.clear();
|
|
221
|
+
freshness.clear();
|
|
222
|
+
checking.clear();
|
|
223
|
+
};
|
package/engine/orm/rlsPolicy.js
CHANGED
|
@@ -276,7 +276,18 @@ export const buildRlsSyncPlan = (table, rlsRows, policyRows) => {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
const live = (Array.isArray(policyRows) ? policyRows : []).map(readPolicyRow);
|
|
279
|
-
|
|
279
|
+
/*
|
|
280
|
+
* ⚠️⚠️ CASE-INSENSITIVE, because PostgreSQL folds unquoted identifiers. A policy created on
|
|
281
|
+
* `ST_OUTLET` comes back from pg_policies as `st_outlet_tenant_write`, while `writePolicyName`
|
|
282
|
+
* keeps the DECLARATION's case. Comparing them verbatim never matched, so every policy looked
|
|
283
|
+
* MISSING on every publish, took the create branch, and the guarded CREATE swallowed
|
|
284
|
+
* `duplicate_object` — which meant the live policy was never compared and drift was never
|
|
285
|
+
* corrected. The whole replace-on-drift path below was unreachable.
|
|
286
|
+
*
|
|
287
|
+
* Found live on db 3: a changed write predicate survived `--publish` and `--publish --forceFull`
|
|
288
|
+
* untouched, while the log reported the policy as missing each time.
|
|
289
|
+
*/
|
|
290
|
+
const find = (policy) => live.find((row) => row.name.toLowerCase() === String(policy).toLowerCase());
|
|
280
291
|
|
|
281
292
|
/*
|
|
282
293
|
* Create, or replace when what is live no longer says what it must.
|
|
@@ -307,7 +318,25 @@ export const buildRlsSyncPlan = (table, rlsRows, policyRows) => {
|
|
|
307
318
|
};
|
|
308
319
|
|
|
309
320
|
const readScope = buildTenantPredicate(tenantColumn);
|
|
310
|
-
|
|
321
|
+
/*
|
|
322
|
+
* ⚠️⚠️ THE TENANT TABLE IS NOT A TENANT-SCOPED TABLE, and its write scope is not the same question.
|
|
323
|
+
*
|
|
324
|
+
* §9.2 makes this check SCALAR on purpose, and for a scoped table that stands: its tenant column
|
|
325
|
+
* says which outlet a row BELONGS TO, so checking against the whole read set would let a screen
|
|
326
|
+
* opened in one context file a row into another. That is the data-corruption vector.
|
|
327
|
+
*
|
|
328
|
+
* On the TENANT TABLE the tenant column is the row's own `id`. Writing there renames the outlet;
|
|
329
|
+
* it cannot misfile anything into it, because the row IS the outlet. Holding it to the scalar
|
|
330
|
+
* active tenant meant an administrator bound to two outlets could only ever edit whichever one
|
|
331
|
+
* their DEVICE was assigned to -- and the console that sets that assignment is itself an outlet
|
|
332
|
+
* list. Live on db 3: user 3 holds both outlets, and editing either answered 403 until the active
|
|
333
|
+
* tenant was set, after which the OTHER one answered 403.
|
|
334
|
+
*
|
|
335
|
+
* So the relaxation is scoped to the tenant table and nowhere else.
|
|
336
|
+
*/
|
|
337
|
+
const isTenantTable = table?.isTenantTable === true;
|
|
338
|
+
const writeCheck = isTenantTable ? readScope : buildActiveTenantCheck(tenantColumn);
|
|
339
|
+
const writeCheckSetting = isTenantTable ? TENANT_SETTING : ACTIVE_TENANT_SETTING;
|
|
311
340
|
|
|
312
341
|
ensure(
|
|
313
342
|
readPolicyName(name),
|
|
@@ -325,12 +354,16 @@ export const buildRlsSyncPlan = (table, rlsRows, policyRows) => {
|
|
|
325
354
|
*/
|
|
326
355
|
ensure(
|
|
327
356
|
writePolicyName(name),
|
|
328
|
-
`write predicate (${tenantColumn} against ${TENANT_SETTING}, checked against ${
|
|
357
|
+
`write predicate (${tenantColumn} against ${TENANT_SETTING}, checked against ${writeCheckSetting})`,
|
|
329
358
|
`CREATE POLICY ${writePolicyName(name)} ON ${name} FOR ALL USING (${readScope}) WITH CHECK (${writeCheck})`,
|
|
359
|
+
/* ⚠️ A check naming the OTHER setting is drift in both directions: a scoped table carrying the
|
|
360
|
+
tenant table's relaxed check reopens §9.2, and a tenant table still carrying the scalar one
|
|
361
|
+
keeps an administrator locked to one outlet. */
|
|
330
362
|
(row) =>
|
|
331
363
|
row.cmd === "ALL" &&
|
|
332
364
|
mentions(row.qual, tenantColumn, TENANT_SETTING) &&
|
|
333
|
-
mentions(row.check, tenantColumn,
|
|
365
|
+
mentions(row.check, tenantColumn, writeCheckSetting) &&
|
|
366
|
+
!mentions(row.check, tenantColumn, isTenantTable ? ACTIVE_TENANT_SETTING : TENANT_SETTING),
|
|
334
367
|
);
|
|
335
368
|
|
|
336
369
|
/* See insertPolicyName: without this a new tenant row could never be created. */
|
package/engine/orm/tenantApi.js
CHANGED
|
@@ -326,11 +326,29 @@ export const createTenantApi = ({
|
|
|
326
326
|
* §7.3 lists only `revoke`. Without this the correction is inexpressible through the API, which
|
|
327
327
|
* leaves a direct table write as the only option — the exact thing §7.3 exists to prevent.
|
|
328
328
|
*/
|
|
329
|
-
const deactivate = async ({ id }) => {
|
|
329
|
+
const deactivate = async ({ id = null, sourceRef = null }) => {
|
|
330
|
+
/*
|
|
331
|
+
* ⚠️ Addressable by sourceRef as `revoke` is, and for a reason found live: a swap or a cancel
|
|
332
|
+
* of a FUTURE-dated duty revokes nothing (revoke matches only rows in force, since stamping
|
|
333
|
+
* `expired_at = now()` on a future row would violate `expired_at > issued_at`), leaving the
|
|
334
|
+
* previous holder a grant for a shift they no longer have. §7.2's correction is the right one
|
|
335
|
+
* — the row never took effect — but the bridge knows only the duty it created, not the
|
|
336
|
+
* assignment id. Without this the correction was inexpressible through the API.
|
|
337
|
+
*/
|
|
338
|
+
if (id === null && sourceRef === null) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
"deactivate needs an id or a sourceRef — refusing to deactivate every assignment (Doc 4 §7.3).",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const target =
|
|
344
|
+
id !== null
|
|
345
|
+
? `ut.ID = ${literal(assertUuid(id, "id"))}::uuid`
|
|
346
|
+
: `ut.SOURCE_REF = ${literal(assertUuid(sourceRef, "sourceRef"))}::uuid`;
|
|
347
|
+
|
|
330
348
|
const rows =
|
|
331
349
|
(await run(
|
|
332
350
|
`UPDATE SYS$USER_TENANT ut SET ACTIVE = false ` +
|
|
333
|
-
`WHERE
|
|
351
|
+
`WHERE ${target} AND ut.ACTIVE ` +
|
|
334
352
|
"RETURNING ut.USER_ID",
|
|
335
353
|
)) ?? [];
|
|
336
354
|
for (const user of new Set(rows.map((row) => row?.user_id ?? row?.USER_ID))) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Doc 6 §8.3 — which usergroups a VERIFIED caller holds.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ WHY THIS IS PLATFORM CODE. `hrextensionlib.maySupervise` compares `hr_config.rights.dutyExtend`
|
|
5
|
+
* against the groups its actor holds, and Doc 5 §1–2 forbids a Layer 3 domain from touching any
|
|
6
|
+
* `sys$` table — its own tests enforce that. So the domain states the right it needs and the platform
|
|
7
|
+
* answers who holds what; the script receives names and never learns where they came from.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IT ANSWERS ONLY FOR AN ALREADY-VERIFIED USER ID. Nothing here authenticates anybody: the id
|
|
10
|
+
* comes from inside a signature (security/internalPrincipal.js), and handing this a caller-supplied
|
|
11
|
+
* id would let anyone enumerate somebody else's rights.
|
|
12
|
+
*/
|
|
13
|
+
import { logger } from "../../logger.js";
|
|
14
|
+
|
|
15
|
+
const MEMBERSHIP_TABLE = "sys$ugmember";
|
|
16
|
+
const GROUP_TABLE = "sys$usergroup";
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* The statement, or null when the id is not a plain number.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ NUMERIC OR NOTHING. This id is interpolated, so a value that is not a number must never reach
|
|
22
|
+
* the statement — returning null is the fail-closed answer, and the caller turns it into "no groups".
|
|
23
|
+
*/
|
|
24
|
+
export const buildUserGroupsSql = (userId) => {
|
|
25
|
+
/* ⚠️ Reject the empties BEFORE coercing: Number(null) and Number([]) are both 0, a perfectly
|
|
26
|
+
valid-looking integer that would build a statement asking about user zero. */
|
|
27
|
+
if (userId === null || userId === undefined || userId === "") return null;
|
|
28
|
+
if (typeof userId === "object" || typeof userId === "boolean") return null;
|
|
29
|
+
const id = Number(userId);
|
|
30
|
+
if (!Number.isInteger(id)) return null;
|
|
31
|
+
return (
|
|
32
|
+
`SELECT g.group_name FROM ${MEMBERSHIP_TABLE} m ` +
|
|
33
|
+
`INNER JOIN ${GROUP_TABLE} g ON g.id = m."sys$usergroup_id" ` +
|
|
34
|
+
`WHERE m.user_id = ${id}`
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* The group names this user holds, or [].
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ FAIL-CLOSED IN EVERY DIRECTION. An unreadable table, an odd row shape, a missing id — all yield
|
|
42
|
+
* NO groups. A rights check reading an empty list refuses, which is the safe outcome; a read that
|
|
43
|
+
* failed open would hand out the supervisor right on a database hiccup.
|
|
44
|
+
*/
|
|
45
|
+
export const resolveUserGroups = async ({ exec, dbIndex, userId }) => {
|
|
46
|
+
const sql = buildUserGroupsSql(userId);
|
|
47
|
+
if (!sql || typeof exec !== "function") return [];
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const rows = await exec(sql, dbIndex);
|
|
51
|
+
if (!Array.isArray(rows)) return [];
|
|
52
|
+
return rows
|
|
53
|
+
.map((row) => String(row?.group_name ?? row?.GROUP_NAME ?? "").trim())
|
|
54
|
+
.filter((name) => name.length > 0);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
logger.warn(
|
|
57
|
+
`[principal] could not read usergroups for user ${userId}: ${error?.message ?? error}. ` +
|
|
58
|
+
"Treating the caller as holding none, so any right checked against them is refused.",
|
|
59
|
+
);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
};
|
package/package.json
CHANGED
package/scheduler/datalib.js
CHANGED
|
@@ -160,10 +160,44 @@ export async function setLibrary(config, libraries, defaultLib) {
|
|
|
160
160
|
return libs;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/*
|
|
164
|
+
* ⚠️⚠️ WHO THE SERVER VERIFIED IS ASKING — normalised, or null.
|
|
165
|
+
*
|
|
166
|
+
* Doc 6 §8.3 needed a right (`rights.dutyExtend`) checked inside a CLI script, and a script had only
|
|
167
|
+
* caller-shaped data to go on: gating on that would be security theatre, so the supervisor path was
|
|
168
|
+
* shut by construction rather than half-built.
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ NULL IS THE FAIL-CLOSED ANSWER and it is the common one: `/cb` is an unauthenticated webhook
|
|
171
|
+
* surface and cron has no user at all. A script that gates on identity must refuse a null principal,
|
|
172
|
+
* never assume one.
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ Anything it does not positively recognise becomes null. A principal is an authorisation input;
|
|
175
|
+
* "some object arrived" is not the same as "a caller was verified".
|
|
176
|
+
*/
|
|
177
|
+
const normalisePrincipal = (principal) => {
|
|
178
|
+
if (!principal || typeof principal !== "object" || Array.isArray(principal)) return null;
|
|
179
|
+
const actorType = String(principal.actorType ?? "").trim();
|
|
180
|
+
if (!actorType) return null;
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
actorType,
|
|
183
|
+
userId: principal.userId ?? null,
|
|
184
|
+
sessionId: principal.sessionId ?? null,
|
|
185
|
+
dbIndex: principal.dbIndex ?? null,
|
|
186
|
+
subdomain: principal.subdomain ?? null,
|
|
187
|
+
/* Frozen too: a script that could push onto it would widen its own tenant scope. */
|
|
188
|
+
tenantIds: Object.freeze(Array.isArray(principal.tenantIds) ? [...principal.tenantIds] : []),
|
|
189
|
+
/* Doc 6 §8.3 — the usergroups this caller holds, resolved by the platform (a Layer 3 domain
|
|
190
|
+
may not read sys$ tables). Frozen for the same reason: a script that could push onto it
|
|
191
|
+
would grant itself the very right it is about to check. */
|
|
192
|
+
groups: Object.freeze(Array.isArray(principal.groups) ? [...principal.groups] : []),
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
163
196
|
export async function extractFunctionScript(
|
|
164
197
|
selectedConfig,
|
|
165
198
|
data,
|
|
166
199
|
extraLib = {},
|
|
200
|
+
principal = null,
|
|
167
201
|
) {
|
|
168
202
|
const config = getConfig(selectedConfig);
|
|
169
203
|
|
|
@@ -212,6 +246,19 @@ export async function extractFunctionScript(
|
|
|
212
246
|
);
|
|
213
247
|
}
|
|
214
248
|
|
|
249
|
+
/*
|
|
250
|
+
* ⚠️ DEFINED LAST, AND NOT WRITABLE. It has to survive both `...extraLib` above and the loaded
|
|
251
|
+
* libraries merged in just now — a value whose entire purpose is that the script cannot choose it
|
|
252
|
+
* must not be shadowable by anything the script can influence, including a library it names in
|
|
253
|
+
* its own useLibrary.
|
|
254
|
+
*/
|
|
255
|
+
Object.defineProperty(lib, "principal", {
|
|
256
|
+
value: normalisePrincipal(principal),
|
|
257
|
+
writable: false,
|
|
258
|
+
configurable: false,
|
|
259
|
+
enumerable: true,
|
|
260
|
+
});
|
|
261
|
+
|
|
215
262
|
return scriptFn(lib).functions;
|
|
216
263
|
}
|
|
217
264
|
|
|
@@ -199,3 +199,36 @@ export function authorizeRequest(headers, { mode = "observe", exempt = false, no
|
|
|
199
199
|
allowed: exempt || mode === "observe" ? true : valid,
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
+
|
|
203
|
+
/*
|
|
204
|
+
* ⚠️⚠️ Doc 6 §8.3 — WHO IS ASKING, for a CLI script.
|
|
205
|
+
*
|
|
206
|
+
* A CLI script had no identity: the internal cliCommand dispatch "trusts caller-supplied
|
|
207
|
+
* identity/roles" (engine/ext/cliInvoke.js says so in its own header), so §8.3's supervisor path was
|
|
208
|
+
* shut by construction rather than half-built. This turns the SIGNED principal the client already
|
|
209
|
+
* sends on HTTP into one a script can trust.
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ EVERYTHING IT REPORTS COMES FROM INSIDE THE SIGNATURE. `dbIndex`, `subdomain` and `tenantIds`
|
|
212
|
+
* are taken from the verified claims and never from the surrounding request, because a caller that
|
|
213
|
+
* could nominate its own tenant list would be choosing its own authority. The socket payload around
|
|
214
|
+
* this is entirely caller-shaped — `engine/ext/esb.js` already had to learn to ignore a handshake
|
|
215
|
+
* `userId` for the same reason.
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ NULL IS THE NORMAL ANSWER and it is fail-closed: `/cb` is an unauthenticated webhook surface and
|
|
218
|
+
* cron has no user. `mode` decides whether a REQUEST is refused elsewhere; it never fabricates an
|
|
219
|
+
* identity here, so observe and enforce agree on who the caller is.
|
|
220
|
+
*/
|
|
221
|
+
export function principalForCliScript(headers, { mode = "observe", now = Date.now() } = {}) {
|
|
222
|
+
const decision = authorizeRequest(headers, { mode, now });
|
|
223
|
+
const claims = decision.claims;
|
|
224
|
+
if (!claims) return null;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
actorType: "USER",
|
|
228
|
+
userId: claims.userId ?? null,
|
|
229
|
+
sessionId: claims.sid ?? null,
|
|
230
|
+
dbIndex: claims.dbIndex ?? null,
|
|
231
|
+
subdomain: claims.subdomain ?? null,
|
|
232
|
+
tenantIds: Array.isArray(claims.tenantIds) ? claims.tenantIds : [],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -2,6 +2,8 @@ import workerpool from "workerpool";
|
|
|
2
2
|
import { loadCliScript, extractFunctionScript } from "../scheduler/datalib.js";
|
|
3
3
|
import * as db from "../db/db.js";
|
|
4
4
|
import { ensureEntityConfig } from "../engine/domain/entityConfigPrimer.js";
|
|
5
|
+
import { resolveUserGroups } from "../engine/orm/userGroups.js";
|
|
6
|
+
import { execPostgres } from "../engine/domain/dialect.js";
|
|
5
7
|
import { logger } from "../logger.js";
|
|
6
8
|
|
|
7
9
|
// export const run = async (apiConfig, scriptIdentifier, scriptData) => {
|
|
@@ -35,7 +37,15 @@ import { logger } from "../logger.js";
|
|
|
35
37
|
// }
|
|
36
38
|
// };
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
/*
|
|
41
|
+
* ⚠️⚠️ `principal` IS A SEPARATE ARGUMENT, NOT A FIELD ON `scriptData`.
|
|
42
|
+
*
|
|
43
|
+
* Every entrance builds `scriptData` out of what the caller sent, and script authors are trained
|
|
44
|
+
* to unwrap `scriptData.body` (the BPM effect wrapper). A principal living in there would be both
|
|
45
|
+
* forgeable by the caller and easy to read by mistake. Kept apart, it can only come from the code
|
|
46
|
+
* that actually verified it — see scheduler/datalib.js normalisePrincipal.
|
|
47
|
+
*/
|
|
48
|
+
export const run = async (apiConfig, scriptIdentifier, scriptData, principal = null) => {
|
|
39
49
|
const searchBy =
|
|
40
50
|
typeof scriptIdentifier === "object"
|
|
41
51
|
? scriptIdentifier.searchBy
|
|
@@ -196,10 +206,32 @@ export const run = async (apiConfig, scriptIdentifier, scriptData) => {
|
|
|
196
206
|
injectESBMethods(extraLib);
|
|
197
207
|
injectDBMethods(extraLib, apiConfig);
|
|
198
208
|
|
|
209
|
+
/*
|
|
210
|
+
* Doc 6 §8.3 — the groups a VERIFIED caller holds, resolved HERE rather than by the script.
|
|
211
|
+
*
|
|
212
|
+
* ⚠️ A Layer 3 domain may not read `sys$` tables (Doc 5 §1–2, enforced by its own tests), so it
|
|
213
|
+
* states the right it needs and the platform answers who holds what. Fail-closed: an
|
|
214
|
+
* unreadable membership table yields none, and a rights check against an empty list refuses.
|
|
215
|
+
*
|
|
216
|
+
* ⚠️ Only for a principal that was actually verified — the id comes from inside a signature,
|
|
217
|
+
* never from the request.
|
|
218
|
+
*/
|
|
219
|
+
const principalWithGroups = principal
|
|
220
|
+
? {
|
|
221
|
+
...principal,
|
|
222
|
+
groups: await resolveUserGroups({
|
|
223
|
+
exec: execPostgres,
|
|
224
|
+
dbIndex: db.resolveDbIndex(apiConfig),
|
|
225
|
+
userId: principal.userId,
|
|
226
|
+
}),
|
|
227
|
+
}
|
|
228
|
+
: null;
|
|
229
|
+
|
|
199
230
|
const functions = await extractFunctionScript(
|
|
200
231
|
apiConfig,
|
|
201
232
|
await loadCliScript(apiConfig, searchBy, searchValue),
|
|
202
233
|
extraLib,
|
|
234
|
+
principalWithGroups,
|
|
203
235
|
);
|
|
204
236
|
|
|
205
237
|
const result =
|
package/worker/cliWorkerPool.js
CHANGED
|
@@ -36,6 +36,12 @@ export const execCLIScriptWorker = async (
|
|
|
36
36
|
apiConfig,
|
|
37
37
|
scriptName,
|
|
38
38
|
scriptData,
|
|
39
|
+
/*
|
|
40
|
+
* ⚠️ WHO THE ENTRANCE VERIFIED, or null. Each caller passes only what it has actually
|
|
41
|
+
* verified: `/cb` is an unauthenticated webhook surface and cron has no user, so both pass
|
|
42
|
+
* nothing. Kept OUT of `scriptData` on purpose — that is the caller's own payload.
|
|
43
|
+
*/
|
|
44
|
+
principal = null,
|
|
39
45
|
) => {
|
|
40
46
|
// callback
|
|
41
47
|
// {
|
|
@@ -78,7 +84,10 @@ export const execCLIScriptWorker = async (
|
|
|
78
84
|
}
|
|
79
85
|
const safeConfig = JSON.parse(JSON.stringify(apiConfig || {}));
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
/* Serialised like everything else crossing the thread boundary; normalised on arrival. */
|
|
88
|
+
const safePrincipal = principal ? JSON.parse(JSON.stringify(principal)) : null;
|
|
89
|
+
|
|
90
|
+
return cliScriptWorkerPool.exec("run", [safeConfig, scriptName, safeData, safePrincipal], {
|
|
82
91
|
on: (message) => {
|
|
83
92
|
// Catch the relay and bounce it to the main thread
|
|
84
93
|
if (message && message.type === "IPC_RELAY") {
|