@memberjunction/codegen-lib 6.1.0-edge.2 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/README.md +39 -22
- package/dist/Angular/angular-codegen.d.ts +0 -8
- package/dist/Angular/angular-codegen.d.ts.map +1 -1
- package/dist/Angular/angular-codegen.js +32 -22
- package/dist/Angular/angular-codegen.js.map +1 -1
- package/dist/Angular/entity-data-grid-related-entity-component.js +2 -2
- package/dist/Angular/entity-data-grid-related-entity-component.js.map +1 -1
- package/dist/Angular/related-entity-components.d.ts +1 -1
- package/dist/Angular/related-entity-components.js +1 -1
- package/dist/Database/codeGenDatabaseProvider.d.ts +101 -0
- package/dist/Database/codeGenDatabaseProvider.d.ts.map +1 -1
- package/dist/Database/codeGenDatabaseProvider.js +77 -10
- package/dist/Database/codeGenDatabaseProvider.js.map +1 -1
- package/dist/Database/manage-metadata.d.ts +292 -2
- package/dist/Database/manage-metadata.d.ts.map +1 -1
- package/dist/Database/manage-metadata.js +1176 -45
- package/dist/Database/manage-metadata.js.map +1 -1
- package/dist/Database/materializationAnalysis.d.ts +241 -0
- package/dist/Database/materializationAnalysis.d.ts.map +1 -0
- package/dist/Database/materializationAnalysis.js +415 -0
- package/dist/Database/materializationAnalysis.js.map +1 -0
- package/dist/Database/materializationBroadRender.d.ts +67 -0
- package/dist/Database/materializationBroadRender.d.ts.map +1 -0
- package/dist/Database/materializationBroadRender.js +104 -0
- package/dist/Database/materializationBroadRender.js.map +1 -0
- package/dist/Database/materializationDrift.d.ts +59 -0
- package/dist/Database/materializationDrift.d.ts.map +1 -0
- package/dist/Database/materializationDrift.js +85 -0
- package/dist/Database/materializationDrift.js.map +1 -0
- package/dist/Database/materializationParamClassifier.d.ts +92 -0
- package/dist/Database/materializationParamClassifier.d.ts.map +1 -0
- package/dist/Database/materializationParamClassifier.js +224 -0
- package/dist/Database/materializationParamClassifier.js.map +1 -0
- package/dist/Database/materializationParamVerifier.d.ts +71 -0
- package/dist/Database/materializationParamVerifier.d.ts.map +1 -0
- package/dist/Database/materializationParamVerifier.js +302 -0
- package/dist/Database/materializationParamVerifier.js.map +1 -0
- package/dist/Database/materializationSqlAst.d.ts +78 -0
- package/dist/Database/materializationSqlAst.d.ts.map +1 -0
- package/dist/Database/materializationSqlAst.js +142 -0
- package/dist/Database/materializationSqlAst.js.map +1 -0
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts +68 -21
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js +303 -197
- package/dist/Database/providers/postgresql/PostgreSQLCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts +2 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.d.ts.map +1 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js +119 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLCodeGenProvider.materialization.test.js.map +1 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts +2 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.d.ts.map +1 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js +155 -0
- package/dist/Database/providers/postgresql/__tests__/PostgreSQLHierarchyFunctions.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts +60 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.d.ts.map +1 -1
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js +294 -0
- package/dist/Database/providers/sqlserver/SQLServerCodeGenProvider.js.map +1 -1
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js +547 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewEmissionGoldenMaster.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js +721 -0
- package/dist/Database/providers/sqlserver/__tests__/BaseViewRegenDecision.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js +78 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerCodeGenProvider.materialization.test.js.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts +2 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.d.ts.map +1 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js +155 -0
- package/dist/Database/providers/sqlserver/__tests__/SQLServerHierarchyTVF.test.js.map +1 -0
- package/dist/Database/sql_codegen.d.ts +21 -5
- package/dist/Database/sql_codegen.d.ts.map +1 -1
- package/dist/Database/sql_codegen.js +84 -26
- package/dist/Database/sql_codegen.js.map +1 -1
- package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/action_subclasses_codegen.js +4 -4
- package/dist/Misc/action_subclasses_codegen.js.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.d.ts +44 -0
- package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
- package/dist/Misc/entity_subclasses_codegen.js +369 -156
- package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
- package/dist/Misc/graphql_server_codegen.d.ts +4 -58
- package/dist/Misc/graphql_server_codegen.d.ts.map +1 -1
- package/dist/Misc/graphql_server_codegen.js +22 -236
- package/dist/Misc/graphql_server_codegen.js.map +1 -1
- package/dist/Misc/sql_logging.d.ts +25 -0
- package/dist/Misc/sql_logging.d.ts.map +1 -1
- package/dist/Misc/sql_logging.js +51 -8
- package/dist/Misc/sql_logging.js.map +1 -1
- package/package.json +30 -30
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { SQLServerDialect, PostgreSQLDialect } from '@memberjunction/sql-dialect';
|
|
1
2
|
import { CodeGenDatabaseProvider } from './codeGenDatabaseProvider.js';
|
|
3
|
+
import { analyzeQueryForMaterialization, detectAggregationKeyColumns, detectAdditiveMeasures, MATERIALIZATION_SURROGATE_COLUMN } from './materializationAnalysis.js';
|
|
4
|
+
import { evaluateMaterializationDrift } from './materializationDrift.js';
|
|
5
|
+
import { classifyQueryParameters, buildHeldValues } from './materializationParamClassifier.js';
|
|
6
|
+
import { buildBroadRowFilterSQL } from './materializationBroadRender.js';
|
|
7
|
+
import { QueryParameterProcessor } from '@memberjunction/query-processor';
|
|
2
8
|
// Side-effect import — registers `SQLServerCodeGenProvider` with `MJGlobal.ClassFactory`
|
|
3
9
|
// under the `'sqlserver'` key via its `@RegisterClass` decorator. Without this import,
|
|
4
10
|
// `ClassFactory.CreateInstance(CodeGenDatabaseProvider, 'sqlserver')` returns nothing
|
|
5
11
|
// and the SS code path silently fails.
|
|
6
12
|
import './providers/sqlserver/SQLServerCodeGenProvider.js';
|
|
7
13
|
import { configInfo, currentWorkingDirectory, dbPlatform, getSettingValue, mj_core_schema, outputDir } from '../Config/config.js';
|
|
8
|
-
import { EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, SeverityType } from "@memberjunction/core";
|
|
14
|
+
import { CodeNameFromString, EntityInfo, ExternalDataSourceReadRouter, ExtractActualDefaultValue, LogError, LogStatus, Metadata, RunQuerySQLFilterManager, SeverityType } from "@memberjunction/core";
|
|
9
15
|
import { MJEntityFieldSchema } from "@memberjunction/core-entities";
|
|
10
16
|
import { logError, logMessage, logStatus, startSpinner, updateSpinner, succeedSpinner } from "../Misc/status_logging.js";
|
|
11
17
|
import { SQLUtilityBase } from "./sql.js";
|
|
@@ -15,7 +21,7 @@ import { CodeGenReporter } from "../Misc/codegen-reporter.js";
|
|
|
15
21
|
import { applySearchableFieldsCap, entityLevelEnableBlockedReason, isNarrativeFieldName, normalizePredicate, normalizeSmartFieldResultShape, } from "./search-guardrails.js";
|
|
16
22
|
import { mapExternalNativeTypeToMJ } from "../Misc/externalTypeMapping.js";
|
|
17
23
|
import { SQLParser } from "@memberjunction/sql-parser";
|
|
18
|
-
import { createDisplayName, generatePluralName, MJGlobal, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
|
|
24
|
+
import { createDisplayName, generatePluralName, MJGlobal, ResolveSingleEntityResourceTarget, stripTrailingChars, UUIDsEqual } from "@memberjunction/global";
|
|
19
25
|
import { v4 as uuidv4 } from 'uuid';
|
|
20
26
|
import * as fs from 'fs';
|
|
21
27
|
import path from 'path';
|
|
@@ -48,22 +54,30 @@ export class ManageMetadataBase {
|
|
|
48
54
|
// ─── End Dialect Infrastructure ───────────────────────────────────
|
|
49
55
|
this._sqlUtilityObject = MJGlobal.Instance.ClassFactory.CreateInstance(SQLUtilityBase);
|
|
50
56
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* ClassFactory) and sync its `EntityField` rows to match — the remote equivalent of reading the
|
|
54
|
-
* local INFORMATION_SCHEMA for a virtual/view entity. No-op when there are no external entities.
|
|
57
|
+
* True if the MaterializedResult table exists. createNewEntities and detectMaterializationDrift gate their
|
|
58
|
+
* references to it on this so codegen doesn't throw on a DB lacking the materialization schema. Cached per run.
|
|
55
59
|
*/
|
|
60
|
+
this._materializedResultTableExists = null;
|
|
56
61
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* Per-run cache of the entity NAMES (trimmed + lowercased) that an API-KEY row filter
|
|
63
|
+
* (`APIKeyScope.RowFilterID`) or an API-APPLICATION row ceiling (`APIApplicationScope.RowFilterID`) binds to.
|
|
64
|
+
*
|
|
65
|
+
* `'unknown'` — the INITIAL value — means "not loaded, or could not be enumerated", and makes
|
|
66
|
+
* {@link entityHasRowLevelRestriction} treat EVERY entity as row-restricted. That is the fail-CLOSED
|
|
67
|
+
* direction on purpose: refusing to materialize costs nothing (the entity/query stays live-only), while
|
|
68
|
+
* materializing an entity whose API-key row filter we failed to see is the precise leak these gates exist to
|
|
69
|
+
* prevent. Loaded by {@link loadAPIKeyRowFilterTargets} at the top of every materialization entry point.
|
|
70
|
+
* `protected` (not `private`) so a unit-test seam can declare "this fixture configures no API-key row filters".
|
|
65
71
|
*/
|
|
66
|
-
this.
|
|
72
|
+
this.apiKeyRowFilterTargets = 'unknown';
|
|
73
|
+
/**
|
|
74
|
+
* THE cached INFORMATION_SCHEMA column-existence probe against the MJ core schema — cross-platform, and
|
|
75
|
+
* cached for the run (the schema cannot change mid-run). Every column gate in this class routes through
|
|
76
|
+
* it: {@link entityHasExternalDataSourceColumn}, {@link queryHasExternalDataSourceColumn},
|
|
77
|
+
* {@link queryHasIsMaterializedColumn} and {@link loadAPIKeyRowFilterTargets} are each a one-line call,
|
|
78
|
+
* so a new gate has no reason to hand-roll a fourth copy of the same query and its own cache field.
|
|
79
|
+
*/
|
|
80
|
+
this._coreSchemaColumnExists = new Map();
|
|
67
81
|
}
|
|
68
82
|
/**
|
|
69
83
|
* Returns the CodeGenDatabaseProvider for the current database platform.
|
|
@@ -398,6 +412,22 @@ export class ManageMetadataBase {
|
|
|
398
412
|
ForeignKeys: Array.isArray(ve.ForeignKeys) ? ve.ForeignKeys : undefined,
|
|
399
413
|
}));
|
|
400
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Extracts the MaterializedBaseViews array from the additionalSchemaInfo config file.
|
|
417
|
+
* Each entry declares a 1:1 base-view materialization of an existing entity (plan §4.1).
|
|
418
|
+
*/
|
|
419
|
+
extractMaterializedBaseViewsFromConfig(config) {
|
|
420
|
+
const decls = config.MaterializedBaseViews;
|
|
421
|
+
if (!Array.isArray(decls))
|
|
422
|
+
return [];
|
|
423
|
+
return decls.map((d) => ({
|
|
424
|
+
EntityName: d.EntityName || undefined,
|
|
425
|
+
BaseTable: d.BaseTable || undefined,
|
|
426
|
+
SchemaName: d.SchemaName || undefined,
|
|
427
|
+
RefreshSchedule: d.RefreshSchedule || undefined,
|
|
428
|
+
IntendedWorkload: d.IntendedWorkload || undefined,
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
401
431
|
/**
|
|
402
432
|
* Extracts ISARelationships array from the additionalSchemaInfo config file.
|
|
403
433
|
* The config may contain a top-level "ISARelationships" key with an array of
|
|
@@ -657,6 +687,34 @@ export class ManageMetadataBase {
|
|
|
657
687
|
}
|
|
658
688
|
return { success: true, createdCount, updatedCount };
|
|
659
689
|
}
|
|
690
|
+
/**
|
|
691
|
+
* Builds the entity lookup behind {@link processISARelationshipConfig}: match on Name first, else
|
|
692
|
+
* on BaseTable, optionally constrained to a schema.
|
|
693
|
+
*
|
|
694
|
+
* The schema predicate is composed CONDITIONALLY rather than written as
|
|
695
|
+
* `(@SchemaName IS NULL OR SchemaName = @SchemaName)`. That form is fatal on PostgreSQL: the
|
|
696
|
+
* parameter's only unambiguous use is `$n IS NULL`, which gives the planner no type to infer, so
|
|
697
|
+
* the whole statement fails to prepare with "could not determine data type of parameter $n".
|
|
698
|
+
* SQL Server infers the type from the other side of the OR and never saw the problem.
|
|
699
|
+
*
|
|
700
|
+
* The failure was silent in the worst way — processISARelationshipConfig catches per-relationship
|
|
701
|
+
* errors and logs them, so CodeGen ran to completion with a zero exit code while every declared
|
|
702
|
+
* IS-A relationship on PostgreSQL was quietly skipped, leaving Entity.ParentID NULL. Downstream
|
|
703
|
+
* that means no mirrored parent fields, no parent JOIN in the child base view, and a child whose
|
|
704
|
+
* Save() never writes the parent row.
|
|
705
|
+
*
|
|
706
|
+
* Emitting the predicate only when a schema was supplied keeps the parameter list free of
|
|
707
|
+
* type-ambiguous entries and is portable to both dialects without a cast.
|
|
708
|
+
*/
|
|
709
|
+
buildISAEntityLookupSQL(schema, selectBody, nameParam, schemaName) {
|
|
710
|
+
const schemaPredicate = schemaName ? ` AND SchemaName = @ISASchemaName` : '';
|
|
711
|
+
const sql = `
|
|
712
|
+
${this.selectTop(1, selectBody, `FROM ${this.qs(schema, 'vwEntities')}
|
|
713
|
+
WHERE Name = @${nameParam}
|
|
714
|
+
OR (BaseTable = @${nameParam}${schemaPredicate})`, `CASE WHEN Name = @${nameParam} THEN 0 ELSE 1 END`)}
|
|
715
|
+
`;
|
|
716
|
+
return { sql, params: schemaName ? { 'ISASchemaName': schemaName } : {} };
|
|
717
|
+
}
|
|
660
718
|
/**
|
|
661
719
|
* Processes IS-A relationship configurations from the additionalSchemaInfo config.
|
|
662
720
|
* For each configured relationship, looks up both entities by name (or by table name
|
|
@@ -675,11 +733,11 @@ export class ManageMetadataBase {
|
|
|
675
733
|
for (const rel of relationships) {
|
|
676
734
|
try {
|
|
677
735
|
// Look up the parent entity — try by Name first, then by BaseTable within the given schema
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
736
|
+
const parentLookup = this.buildISAEntityLookupSQL(schema, 'ID, Name', 'ParentName', rel.SchemaName);
|
|
737
|
+
const parentResult = await this.runQueryWithParams(pool, parentLookup.sql, {
|
|
738
|
+
'ParentName': rel.ParentEntity,
|
|
739
|
+
...parentLookup.params,
|
|
740
|
+
});
|
|
683
741
|
if (parentResult.recordset.length === 0) {
|
|
684
742
|
logError(` > IS-A config: parent entity "${rel.ParentEntity}" not found — skipping`);
|
|
685
743
|
continue;
|
|
@@ -687,11 +745,11 @@ export class ManageMetadataBase {
|
|
|
687
745
|
const parentId = parentResult.recordset[0].ID;
|
|
688
746
|
const parentName = parentResult.recordset[0].Name;
|
|
689
747
|
// Look up the child entity — same strategy
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
748
|
+
const childLookup = this.buildISAEntityLookupSQL(schema, 'ID, Name, ParentID', 'ChildName', rel.SchemaName);
|
|
749
|
+
const childResult = await this.runQueryWithParams(pool, childLookup.sql, {
|
|
750
|
+
'ChildName': rel.ChildEntity,
|
|
751
|
+
...childLookup.params,
|
|
752
|
+
});
|
|
695
753
|
if (childResult.recordset.length === 0) {
|
|
696
754
|
logError(` > IS-A config: child entity "${rel.ChildEntity}" not found — skipping`);
|
|
697
755
|
continue;
|
|
@@ -951,6 +1009,695 @@ export class ManageMetadataBase {
|
|
|
951
1009
|
}
|
|
952
1010
|
return { success: true, createdCount };
|
|
953
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Processes base-view materialization declarations from additionalSchemaInfo (plan §4.1).
|
|
1014
|
+
* Each declares a 1:1 snapshot of an existing entity's base view. Because the shape is
|
|
1015
|
+
* identical to the source entity, NO new entity is minted — the existing entity is reused
|
|
1016
|
+
* (its RLS applies unchanged, §6.1). For each declaration this:
|
|
1017
|
+
* 1. resolves the source entity,
|
|
1018
|
+
* 2. derives the column shape from the entity's base-view fields (SQLFullType),
|
|
1019
|
+
* 3. emits the physical table (materialized_<Name>) + wrapper view (materialized_vw<Name>)
|
|
1020
|
+
* via the cross-engine DDL primitive (create-if-absent, so a migration-provided table
|
|
1021
|
+
* is reused rather than clobbered — §12),
|
|
1022
|
+
* 4. upserts the "MJ: Materialized Results" row (SourceType='EntityBaseView') keyed on the
|
|
1023
|
+
* source entity.
|
|
1024
|
+
* Population is the refresh driver's job (a later phase); rows land in Status='Building'.
|
|
1025
|
+
*/
|
|
1026
|
+
async processBaseViewMaterializations(pool) {
|
|
1027
|
+
const config = ManageMetadataBase.getSoftPKFKConfig();
|
|
1028
|
+
if (!config)
|
|
1029
|
+
return { success: true, processedCount: 0 };
|
|
1030
|
+
const decls = this.extractMaterializedBaseViewsFromConfig(config);
|
|
1031
|
+
if (decls.length === 0)
|
|
1032
|
+
return { success: true, processedCount: 0 };
|
|
1033
|
+
// Gate on the MaterializedResult table existing. Base-view materializations are DECLARED in config, but the
|
|
1034
|
+
// target DB may not have the materialization migration applied yet (e.g. the PostgreSQL parallel world, or a
|
|
1035
|
+
// fresh env sharing a config). The MaterializedResult upsert below would otherwise throw and abort the whole
|
|
1036
|
+
// codegen pass. Unlike the unconditional gates on the sibling methods this one logs, because the user asked
|
|
1037
|
+
// for these materializations — a silent skip would look like the feature quietly did nothing.
|
|
1038
|
+
if (!(await this.materializedResultTableExists(pool))) {
|
|
1039
|
+
logStatus(` > Base-view materialization: ${decls.length} declaration${decls.length === 1 ? '' : 's'} in config but the MaterializedResult table is absent on this database — skipping (has the materialization migration been applied?)`);
|
|
1040
|
+
return { success: true, processedCount: 0 };
|
|
1041
|
+
}
|
|
1042
|
+
// Enumerate the API-key row-filter layer BEFORE any leak gate below evaluates it. Until this runs the cache
|
|
1043
|
+
// reads 'unknown' and every entity is treated as row-restricted (fail closed) — see loadAPIKeyRowFilterTargets.
|
|
1044
|
+
await this.loadAPIKeyRowFilterTargets(pool);
|
|
1045
|
+
// Refresh so entity.Fields reflect this run's field sync before we snapshot the shape.
|
|
1046
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1047
|
+
await md.Refresh();
|
|
1048
|
+
const coreSchema = mj_core_schema();
|
|
1049
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
1050
|
+
const lit = (s) => (s ? `'${esc(s)}'` : 'NULL');
|
|
1051
|
+
let processedCount = 0;
|
|
1052
|
+
for (const decl of decls) {
|
|
1053
|
+
// 1) Resolve the source entity (by name, else by base table [+ schema]).
|
|
1054
|
+
const entity = decl.EntityName
|
|
1055
|
+
? md.EntityByName(decl.EntityName)
|
|
1056
|
+
: md.Entities.find((e) => !!decl.BaseTable &&
|
|
1057
|
+
(e.BaseTable ?? '').trim().toLowerCase() === decl.BaseTable.trim().toLowerCase() &&
|
|
1058
|
+
(!decl.SchemaName || (e.SchemaName ?? '').trim().toLowerCase() === decl.SchemaName.trim().toLowerCase()));
|
|
1059
|
+
if (!entity) {
|
|
1060
|
+
logError(` > Base-view materialization: source entity not found (${decl.EntityName ?? `${decl.SchemaName ?? coreSchema}.${decl.BaseTable}`}) — skipping`);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
// SECURITY (Leak 1 / C2 for base views): refuse to materialize an EXTERNAL entity that is
|
|
1064
|
+
// read-RLS-protected. An external entity's live reads are REFUSED under RLS (the read path throws —
|
|
1065
|
+
// MJ can't enforce RLS WHERE clauses on a remote system), and for external entities the read path
|
|
1066
|
+
// returns at the external-dispatch branch BEFORE any materialized-view swap — so the "base-view reads
|
|
1067
|
+
// re-apply source RLS" exemption is unreachable here. Mirroring the remote rows into a local table
|
|
1068
|
+
// would expose, via any raw query joining the wrapper view, data the live path refuses entirely.
|
|
1069
|
+
// (A LOCAL RLS base-view is safe: its read path swaps to the mirror and re-applies the entity's RLS.)
|
|
1070
|
+
if (entity.ExternalDataSourceID && this.entityHasRowLevelRestriction(entity)) {
|
|
1071
|
+
logError(` > REFUSING base-view materialization of external entity "${entity.Name}": it is row-restricted (role RLS and/or an API-key row filter) and its live reads are refused under RLS, so a local mirror would leak its rows via raw queries over the wrapper view (a mirror cannot reproduce remote row restrictions). Skipping.`);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
// Schema-name symmetry with the refresh path: MaterializationRefresher validates schema/table/view names
|
|
1075
|
+
// as plain SQL identifiers (^[A-Za-z_][A-Za-z0-9_]*$) before building any DDL. The mint quotes at the
|
|
1076
|
+
// dialect level and would otherwise accept a schema name (e.g. "Sales Data", "crm-prod") the refresh then
|
|
1077
|
+
// permanently rejects — the entity would provision cleanly and never refresh (a silent nightly failure).
|
|
1078
|
+
// Refuse up front, symmetrically, with a clear message. (TableName/ViewName are CodeName-derived and
|
|
1079
|
+
// always identifier-safe, so only the source entity's schema can be non-conforming here.)
|
|
1080
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entity.SchemaName ?? '')) {
|
|
1081
|
+
logError(` > REFUSING base-view materialization of entity "${entity.Name}": its schema "${entity.SchemaName}" is not a plain SQL identifier — the refresh path requires one, so materialization is only supported for entities in identifier-named schemas. Skipping.`);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
// 2) Derive the snapshot column shape from the entity's base-view fields.
|
|
1085
|
+
// For EXTERNAL entities, virtual fields are MJ-computed and absent from the remote source, so the
|
|
1086
|
+
// refresh mirror (rebuildFromExternalEntity) only materializes non-virtual columns — the mint column
|
|
1087
|
+
// set MUST match, or the wrapper view would expose virtual columns the refreshed table lacks (breaking
|
|
1088
|
+
// virtual-field reads at the DB AND permanently tripping DriftHold on the next drift scan). LOCAL base
|
|
1089
|
+
// views compute virtual columns in the view itself, so `SELECT * FROM <baseView>` includes them and the
|
|
1090
|
+
// mint keeps all fields — the mint/refresh column sets stay symmetric on both paths.
|
|
1091
|
+
const columns = this.materializedEntityFields(entity)
|
|
1092
|
+
.map((f) => ({
|
|
1093
|
+
Name: f.Name,
|
|
1094
|
+
SQLType: f.SQLFullType,
|
|
1095
|
+
Nullable: f.AllowsNull,
|
|
1096
|
+
IsPrimaryKey: f.IsPrimaryKey,
|
|
1097
|
+
}));
|
|
1098
|
+
if (columns.length === 0) {
|
|
1099
|
+
logError(` > Base-view materialization for "${entity.Name}": no fields resolved — skipping`);
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
// 3) Emit the physical table + wrapper view (create-if-absent; reuse a migration-provided table).
|
|
1103
|
+
const matSchema = entity.SchemaName;
|
|
1104
|
+
const tableName = `materialized_${entity.CodeName}`;
|
|
1105
|
+
const viewName = `materialized_vw${entity.CodeName}`;
|
|
1106
|
+
const tableSQL = this.dbProvider.generateMaterializedTableSQL(matSchema, tableName, columns);
|
|
1107
|
+
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(matSchema, viewName, tableName);
|
|
1108
|
+
// includeBatchSeparator: each is a single GO-free batch (executed via ds.query), but the
|
|
1109
|
+
// migration file needs a GO between statements for Flyway/sqlcmd.
|
|
1110
|
+
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for base-view materialization of entity ${entity.Name}`, false, true);
|
|
1111
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for base-view materialization of entity ${entity.Name}`, false, true);
|
|
1112
|
+
// 4) Upsert the MJ: Materialized Results row, keyed on (SourceType, SourceEntityID).
|
|
1113
|
+
const existing = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'MaterializedResult')} WHERE SourceType = @SourceType AND SourceEntityID = @EntityID`, { SourceType: 'EntityBaseView', EntityID: entity.ID });
|
|
1114
|
+
if (existing.recordset.length > 0) {
|
|
1115
|
+
const sqlUpd = `UPDATE ${this.qs(coreSchema, 'MaterializedResult')}
|
|
1116
|
+
SET SchemaName='${esc(matSchema)}', TableName='${esc(tableName)}', ViewName='${esc(viewName)}',
|
|
1117
|
+
RefreshSchedule=${lit(decl.RefreshSchedule)}, IntendedWorkload=${lit(decl.IntendedWorkload)}
|
|
1118
|
+
WHERE ID='${existing.recordset[0].ID}'`;
|
|
1119
|
+
await this.LogSQLAndExecute(pool, sqlUpd, `Update MJ: Materialized Results for base-view materialization of ${entity.Name}`);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
const newId = this.createNewUUID();
|
|
1123
|
+
const q = (n) => this.qi(n);
|
|
1124
|
+
const sqlIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResult')} (
|
|
1125
|
+
${q('ID')}, ${q('SourceType')}, ${q('SourceEntityID')}, ${q('SchemaName')}, ${q('TableName')}, ${q('ViewName')},
|
|
1126
|
+
${q('ParamMode')}, ${q('RefreshStrategy')}, ${q('RefreshSchedule')}, ${q('Status')}, ${q('IntendedWorkload')},
|
|
1127
|
+
${q('__mj_CreatedAt')}, ${q('__mj_UpdatedAt')} )
|
|
1128
|
+
VALUES ( '${newId}', 'EntityBaseView', '${entity.ID}', '${esc(matSchema)}', '${esc(tableName)}', '${esc(viewName)}',
|
|
1129
|
+
'None', 'FullRebuild', ${lit(decl.RefreshSchedule)}, 'Building', ${lit(decl.IntendedWorkload)},
|
|
1130
|
+
${this.utcNow()}, ${this.utcNow()} )`;
|
|
1131
|
+
await this.LogSQLAndExecute(pool, sqlIns, `Insert MJ: Materialized Results for base-view materialization of ${entity.Name}`);
|
|
1132
|
+
}
|
|
1133
|
+
processedCount++;
|
|
1134
|
+
logStatus(` > Base-view materialization ready for entity "${entity.Name}" → [${matSchema}].[${viewName}]`);
|
|
1135
|
+
}
|
|
1136
|
+
return { success: true, processedCount };
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Phase 2d — classifies a parameterized query's parameters (deterministic render-and-diff) and,
|
|
1140
|
+
* when every parameter is a safe row filter, produces the BROAD source SQL the refresh engine
|
|
1141
|
+
* materializes (the query with its row-filter WHERE predicates removed). Returns the persistence
|
|
1142
|
+
* fields, or a precise refusal (per-parameter reasons) so non-qualifying queries are skipped loudly.
|
|
1143
|
+
*/
|
|
1144
|
+
async classifyParameterizedQueryForMaterialization(pool, queryId, queryName, outputColumns, currentUser) {
|
|
1145
|
+
const coreSchema = mj_core_schema();
|
|
1146
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1147
|
+
// Load the query template + parameter definitions (typed entities for the renderer).
|
|
1148
|
+
const qRes = await this.runQueryWithParams(pool, `SELECT SQL, UsesTemplate FROM ${this.qs(coreSchema, 'Query')} WHERE ID = @QID`, { QID: queryId });
|
|
1149
|
+
const sqlText = qRes.recordset[0]?.SQL ?? '';
|
|
1150
|
+
const usesTemplate = !!qRes.recordset[0]?.UsesTemplate;
|
|
1151
|
+
// NB: QueryParameter has no Sequence column (unlike QueryField) — order by Name for deterministic,
|
|
1152
|
+
// stable classification output. (Ordering only affects the persisted spec's element order; the
|
|
1153
|
+
// read-time predicate is an AND of all entries, so it's order-independent for correctness.)
|
|
1154
|
+
const paramRows = await this.runQueryWithParams(pool, `SELECT Name, Type, SampleValue, DefaultValue, IsRequired, ValidationFilters FROM ${this.qs(coreSchema, 'QueryParameter')} WHERE QueryID = @QID ORDER BY Name`, { QID: queryId });
|
|
1155
|
+
const queryParams = [];
|
|
1156
|
+
for (const row of paramRows.recordset) {
|
|
1157
|
+
const pe = await md.GetEntityObject('MJ: Query Parameters', currentUser);
|
|
1158
|
+
pe.LoadFromData(row);
|
|
1159
|
+
queryParams.push(pe);
|
|
1160
|
+
}
|
|
1161
|
+
// Platform-aware Nunjucks renderer — the same SQL-safe filters production uses.
|
|
1162
|
+
const platform = this.dbProvider.PlatformKey;
|
|
1163
|
+
RunQuerySQLFilterManager.Instance.SetPlatform(platform);
|
|
1164
|
+
const dialect = platform === 'postgresql' ? new PostgreSQLDialect() : new SQLServerDialect();
|
|
1165
|
+
const templateInput = { SQL: sqlText, UsesTemplate: usesTemplate, Parameters: queryParams };
|
|
1166
|
+
const render = (vals) => {
|
|
1167
|
+
const res = QueryParameterProcessor.processQueryTemplate(templateInput, vals, undefined, true);
|
|
1168
|
+
if (!res.success) {
|
|
1169
|
+
throw new Error(res.error ?? 'template render failed');
|
|
1170
|
+
}
|
|
1171
|
+
return res.processedSQL;
|
|
1172
|
+
};
|
|
1173
|
+
const paramDefs = queryParams.map((p) => ({ Name: p.Name, Type: p.Type, SampleValue: p.SampleValue }));
|
|
1174
|
+
// Phase 2 is shipped: enable Bucket-1 row-filter broad materialization. The provider now auto-injects
|
|
1175
|
+
// the read-time predicate from the persisted ReadFilterSpec (bound params), so a RowFilterBroad
|
|
1176
|
+
// materialization is safe to mint. `allowRowFilterBroad` remains the build-level kill switch.
|
|
1177
|
+
const classification = classifyQueryParameters({ queryName, params: paramDefs, outputColumns, dialect, render, allowRowFilterBroad: true });
|
|
1178
|
+
if (!classification.qualification.qualifies) {
|
|
1179
|
+
const detail = classification.perParam.map((pp) => `${pp.name}: ${pp.verdict.reason}`).join('; ');
|
|
1180
|
+
return { qualifies: false, reason: `${classification.qualification.reason ?? 'parameters not materializable'}${detail ? ` — [${detail}]` : ''}` };
|
|
1181
|
+
}
|
|
1182
|
+
if (classification.qualification.paramMode !== 'RowFilterBroad') {
|
|
1183
|
+
return { qualifies: false, reason: `parameterization mode '${classification.qualification.paramMode}' is not supported for materialization in v1 (only RowFilterBroad)` };
|
|
1184
|
+
}
|
|
1185
|
+
// Build the broad source SQL: render a concrete instance (held values) then strip the row-filter predicates.
|
|
1186
|
+
let renderedHeld;
|
|
1187
|
+
try {
|
|
1188
|
+
renderedHeld = render(buildHeldValues(paramDefs));
|
|
1189
|
+
}
|
|
1190
|
+
catch (e) {
|
|
1191
|
+
return { qualifies: false, reason: `could not render the query to build broad SQL: ${e instanceof Error ? e.message : String(e)}` };
|
|
1192
|
+
}
|
|
1193
|
+
const expectedRemovals = classification.qualification.rowFilterColumns.length;
|
|
1194
|
+
const broad = buildBroadRowFilterSQL(renderedHeld, classification.qualification.rowFilterColumns, dialect, expectedRemovals);
|
|
1195
|
+
if (broad.removedCount === 0) {
|
|
1196
|
+
return { qualifies: false, reason: `expected to strip row-filter predicate(s) on [${classification.qualification.rowFilterColumns.join(', ')}] but none were removed from the rendered SQL — refusing to avoid a wrongly-filtered materialization` };
|
|
1197
|
+
}
|
|
1198
|
+
if (broad.ambiguous) {
|
|
1199
|
+
return { qualifies: false, reason: `expected to strip exactly ${expectedRemovals} row-filter parameter predicate(s) on [${classification.qualification.rowFilterColumns.join(', ')}] but matched ${broad.removedCount} — the parameter predicate(s) cannot be cleanly isolated from other static or same-named predicates on those columns, so a broad materialization would include or exclude rows the live query never would. Refusing (query stays live-only).` };
|
|
1200
|
+
}
|
|
1201
|
+
return { qualifies: true, rowFilterColumns: classification.qualification.rowFilterColumns, broadSQL: broad.sql, readFilterSpec: classification.qualification.readFilterSpec };
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Processes query materialization (CodeGen materialization phase, sub-step C — plan §4.2).
|
|
1205
|
+
* Scans queries flagged `IsMaterialized = 1` and, for each that *qualifies* (unparameterized,
|
|
1206
|
+
* has declared output fields — see analyzeQueryForMaterialization, §9/§10):
|
|
1207
|
+
* 1. derives the materialized table's column shape + synthetic surrogate key,
|
|
1208
|
+
* 2. emits the physical table (materialized_<Name>) + wrapper view (materialized_vw<Name>)
|
|
1209
|
+
* via the cross-engine DDL primitive,
|
|
1210
|
+
* 3. mints a read-only Virtual Entity over the wrapper view (idempotent — reuses an existing
|
|
1211
|
+
* one), linked back to the source Query,
|
|
1212
|
+
* 4. upserts the "MJ: Materialized Results" row (SourceType='Query') and its
|
|
1213
|
+
* MaterializedResultQuery join row linking it to the source Query (no direct
|
|
1214
|
+
* SourceQueryID / MaterializedResultID FK columns — the join table breaks the cycle).
|
|
1215
|
+
* Runs BEFORE manageVirtualEntities so the minted entity's fields get synced from the wrapper
|
|
1216
|
+
* view in the same run. Population is the refresh driver's job (later phase); Status='Building'.
|
|
1217
|
+
* Parameterized queries are skipped with a log (deferred to Phase 2).
|
|
1218
|
+
*/
|
|
1219
|
+
async processQueryMaterializations(pool, currentUser) {
|
|
1220
|
+
const coreSchema = mj_core_schema();
|
|
1221
|
+
// Gate the whole method on the IsMaterialized column existing — the flagged-query SELECT below filters on it,
|
|
1222
|
+
// and it (like the MaterializedResult table) is added by the materialization Foundation migration. On a DB
|
|
1223
|
+
// where that migration hasn't run yet the SELECT would throw and abort the entire codegen pass; with no such
|
|
1224
|
+
// column there are no materializations to process anyway. Same defensive pattern as the EDS-column gate below.
|
|
1225
|
+
if (!(await this.queryHasIsMaterializedColumn(pool)))
|
|
1226
|
+
return { success: true, processedCount: 0, mintedCount: 0 };
|
|
1227
|
+
// Gate the ExternalDataSourceID column ref so the SELECT stays valid on a DB without the EDS schema
|
|
1228
|
+
// (PostgreSQL today). When absent, no query can be external, so the flag defaults false everywhere.
|
|
1229
|
+
const queryHasEDSCol = await this.queryHasExternalDataSourceColumn(pool);
|
|
1230
|
+
const flagged = await this.runQueryWithParams(pool, `SELECT ID, Name, SQL${queryHasEDSCol ? ', ExternalDataSourceID' : ''} FROM ${this.qs(coreSchema, 'Query')} WHERE IsMaterialized = ${this.boolLit(true)}`, {});
|
|
1231
|
+
if (flagged.recordset.length === 0)
|
|
1232
|
+
return { success: true, processedCount: 0, mintedCount: 0 };
|
|
1233
|
+
// Symmetry with the three sibling gates: the per-query loop below also reads/writes the MaterializedResult
|
|
1234
|
+
// TABLE (not just the IsMaterialized column). Don't lean on the "same Foundation migration ships both"
|
|
1235
|
+
// coupling — confirm the table itself exists. If a query is flagged but the table is absent (partial schema),
|
|
1236
|
+
// skip cleanly instead of throwing mid-loop and aborting the whole codegen pass.
|
|
1237
|
+
if (!(await this.materializedResultTableExists(pool))) {
|
|
1238
|
+
logStatus(` > Query materialization: ${flagged.recordset.length} query(ies) flagged IsMaterialized but the MaterializedResult table is absent on this database — skipping (has the materialization migration been applied?)`);
|
|
1239
|
+
return { success: true, processedCount: 0, mintedCount: 0 };
|
|
1240
|
+
}
|
|
1241
|
+
// Enumerate the API-key row-filter layer BEFORE the mint-time RLS gate consults it (see
|
|
1242
|
+
// loadAPIKeyRowFilterTargets — an unloaded cache reads 'unknown' and refuses every materialization).
|
|
1243
|
+
await this.loadAPIKeyRowFilterTargets(pool);
|
|
1244
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
1245
|
+
const idLit = (id) => (id ? `'${id}'` : 'NULL');
|
|
1246
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1247
|
+
let processedCount = 0;
|
|
1248
|
+
let mintedCount = 0;
|
|
1249
|
+
for (const q of flagged.recordset) {
|
|
1250
|
+
const queryId = q.ID;
|
|
1251
|
+
const queryName = q.Name;
|
|
1252
|
+
const querySQL = q.SQL ?? '';
|
|
1253
|
+
// Qualifying inputs: parameterized? declared output fields?
|
|
1254
|
+
const paramCountRes = await this.runQueryWithParams(pool, `SELECT COUNT(*) AS n FROM ${this.qs(coreSchema, 'QueryParameter')} WHERE QueryID = @QID`, { QID: queryId });
|
|
1255
|
+
const isParameterized = (paramCountRes.recordset[0]?.n ?? 0) > 0;
|
|
1256
|
+
const fieldsRes = await this.runQueryWithParams(pool, `SELECT Name, SQLFullType, IsComputed FROM ${this.qs(coreSchema, 'QueryField')} WHERE QueryID = @QID ORDER BY Sequence`, { QID: queryId });
|
|
1257
|
+
const fields = fieldsRes.recordset.map((r) => ({ Name: r.Name, SQLFullType: r.SQLFullType, IsComputed: !!r.IsComputed }));
|
|
1258
|
+
// Parameterized queries (Phase 2d): classify via deterministic render-and-diff. A query whose
|
|
1259
|
+
// parameters are all safe row filters materializes BROAD (the row-filter predicate is removed)
|
|
1260
|
+
// and the filter is re-applied at read; anything not provably a safe row filter is refused with
|
|
1261
|
+
// a precise reason. Unparameterized queries skip this entirely.
|
|
1262
|
+
let paramMode = 'None';
|
|
1263
|
+
let rowFilterColumns = [];
|
|
1264
|
+
let broadSQL = null;
|
|
1265
|
+
let readFilterSpec = [];
|
|
1266
|
+
if (isParameterized) {
|
|
1267
|
+
const pc = await this.classifyParameterizedQueryForMaterialization(pool, queryId, queryName, fields.map((f) => f.Name), currentUser);
|
|
1268
|
+
if (!pc.qualifies) {
|
|
1269
|
+
logStatus(` > Skipping materialization of parameterized query "${queryName}": ${pc.reason}`);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
paramMode = 'RowFilterBroad';
|
|
1273
|
+
rowFilterColumns = pc.rowFilterColumns;
|
|
1274
|
+
broadSQL = pc.broadSQL;
|
|
1275
|
+
readFilterSpec = pc.readFilterSpec;
|
|
1276
|
+
}
|
|
1277
|
+
// Phase 3: detect an aggregation key (the grouping columns) so the refresh can compute a stable
|
|
1278
|
+
// hash surrogate (the match key for incremental / dirty-group refresh) instead of a synthetic
|
|
1279
|
+
// row id. Detect against the BROAD SQL for a row-filter query (the actually-materialized shape),
|
|
1280
|
+
// else the raw query SQL. NULL = not keyed → synthetic surrogate + full rebuild (always correct).
|
|
1281
|
+
// Detected BEFORE the shape analysis so the surrogate column can be typed for the keyed case.
|
|
1282
|
+
const detectSQL = paramMode === 'RowFilterBroad' ? (broadSQL ?? '') : querySQL;
|
|
1283
|
+
const matDialect = this.dbProvider.PlatformKey === 'postgresql' ? new PostgreSQLDialect() : new SQLServerDialect();
|
|
1284
|
+
const detectedKeyColumns = detectSQL ? detectAggregationKeyColumns({ sql: detectSQL, dialect: matDialect, fields }) : null;
|
|
1285
|
+
// An EXTERNAL query refreshes via rebuildFromExternalQuery, which fetches all rows through the EDS
|
|
1286
|
+
// driver and writes a SYNTHETIC row-index surrogate — it cannot compute the per-group combined-key
|
|
1287
|
+
// hash and ignores KeyColumns entirely. So minting a keyed (hash-surrogate) entity for an external
|
|
1288
|
+
// query would type the PK varchar/text while the refresh fills it with int row indices (a type +
|
|
1289
|
+
// semantics mismatch). Force such queries un-keyed → synthetic surrogate + FullRebuild, which the
|
|
1290
|
+
// external refresh path supports. (Incremental keyed refresh needs a local __mj_UpdatedAt watermark
|
|
1291
|
+
// the EDS router doesn't expose anyway, so nothing is lost.)
|
|
1292
|
+
const isExternalQuery = queryHasEDSCol && !!q.ExternalDataSourceID;
|
|
1293
|
+
const keyColumns = isExternalQuery ? null : detectedKeyColumns;
|
|
1294
|
+
if (isExternalQuery && detectedKeyColumns && detectedKeyColumns.length > 0) {
|
|
1295
|
+
logStatus(` > Query "${queryName}" is external + aggregation-keyed; the external refresh path uses a synthetic row-index surrogate, so materializing it un-keyed (FullRebuild) to keep the PK type/value consistent.`);
|
|
1296
|
+
}
|
|
1297
|
+
const isKeyed = !!keyColumns && keyColumns.length > 0;
|
|
1298
|
+
const keyColumnsLit = isKeyed ? `'${esc(JSON.stringify(keyColumns))}'` : 'NULL';
|
|
1299
|
+
// Surrogate PK type MUST match what the refresh actually writes into it: a KEYED materialization's
|
|
1300
|
+
// refresh writes the combined-key SHA-256 hash (a fixed-width hex string), so the minted entity's
|
|
1301
|
+
// PK is typed as the hash type; an un-keyed one keeps the synthetic auto-identity. Typing it int
|
|
1302
|
+
// for a keyed materialization would diverge from the post-refresh varchar/text column (breaking PK
|
|
1303
|
+
// typing, keyset pagination, and record lookups against the minted entity).
|
|
1304
|
+
const surrogateSQLType = isKeyed
|
|
1305
|
+
? this.dbProvider.getMaterializedHashSurrogateColumnType()
|
|
1306
|
+
: this.dbProvider.getMaterializedSurrogateColumnType();
|
|
1307
|
+
// The materialized table's column shape is the query's output columns — independent of
|
|
1308
|
+
// parameterization (a row-filter query is materialized broad over the same columns). Pass
|
|
1309
|
+
// isParameterized:false so the analyzer yields the shape; the param gate above already ran.
|
|
1310
|
+
const analysis = analyzeQueryForMaterialization({ queryName, isParameterized: false, fields, surrogateSQLType });
|
|
1311
|
+
if (!analysis.qualifies) {
|
|
1312
|
+
logStatus(` > Skipping materialization of query "${queryName}": ${analysis.reason}`);
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
// SQL literals for the row-filter persistence columns (shared by the insert/update branches).
|
|
1316
|
+
const rowFilterColumnsLit = rowFilterColumns.length > 0 ? `'${esc(JSON.stringify(rowFilterColumns))}'` : 'NULL';
|
|
1317
|
+
const broadSQLLit = broadSQL ? `'${esc(broadSQL)}'` : 'NULL';
|
|
1318
|
+
// Phase 2: the structured read-filter spec the provider injects at read time (bound params).
|
|
1319
|
+
const readFilterSpecLit = readFilterSpec.length > 0 ? `'${esc(JSON.stringify(readFilterSpec))}'` : 'NULL';
|
|
1320
|
+
// Phase 3/4: a keyed aggregation over a SINGLE source table is eligible for incremental in-place
|
|
1321
|
+
// refresh (the refresher still guards at runtime: baseline watermark + delete detection).
|
|
1322
|
+
// Joins/multi-table sources can't be localized by one watermark → keep FullRebuild (§10 bias).
|
|
1323
|
+
// Among eligible ones, a PURELY ADDITIVE aggregation (only SUM/COUNT) uses the MERGE-upsert
|
|
1324
|
+
// 'Incremental' path (in-place, no row churn); non-additive uses 'DirtyGroupRecompute' (delete+insert).
|
|
1325
|
+
const isKeyedSingleSource = isKeyed && !!detectSQL && SQLParser.ExtractTableRefs(detectSQL, matDialect).length === 1;
|
|
1326
|
+
const refreshStrategy = !isKeyedSingleSource
|
|
1327
|
+
? 'FullRebuild'
|
|
1328
|
+
: detectAdditiveMeasures(detectSQL)
|
|
1329
|
+
? 'Incremental'
|
|
1330
|
+
: 'DirtyGroupRecompute';
|
|
1331
|
+
// RLS downgrade gate (§6.2, ships day one) — a materialized query entity does NOT inherit its
|
|
1332
|
+
// source entities' row-level security. Refuse to materialize when RLS-safety cannot be proven, to
|
|
1333
|
+
// prevent a silent privilege escalation. Asymmetric risk (§10): over-restriction is harmless; the
|
|
1334
|
+
// dangerous direction (protected → unprotected) must be loud. The SAME assessment is re-run on every
|
|
1335
|
+
// CodeGen pass by detectMaterializationDrift, so a source that LATER gains RLS holds the existing
|
|
1336
|
+
// materialization AND revokes its read access rather than leaking (see assessQuerySourceRLSSafety).
|
|
1337
|
+
const sourceLinks = await this.runQueryWithParams(pool, `SELECT EntityID FROM ${this.qs(coreSchema, 'vwQueryEntities')} WHERE QueryID = @QID`, { QID: queryId });
|
|
1338
|
+
const rlsVerdict = this.assessQuerySourceRLSSafety(md, sourceLinks.recordset.map((r) => r.EntityID), detectSQL);
|
|
1339
|
+
if (!rlsVerdict.safe) {
|
|
1340
|
+
logError(` > REFUSING to materialize query "${queryName}": ${rlsVerdict.reason}. Run query analysis so its source entities are linked, or use a base-view materialization (which inherits source RLS). Skipping to avoid a silent privilege escalation.`);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
// The resolved source entities (all guaranteed to resolve — the gate above fails closed otherwise)
|
|
1344
|
+
// scope the minted entity's read permissions to the INTERSECTION of source read access (C2). Without
|
|
1345
|
+
// this, the minted entity would receive broad config-default read permissions and a user who cannot
|
|
1346
|
+
// read a source entity could read its rows through the unscoped snapshot.
|
|
1347
|
+
const resolvedSourceEntities = sourceLinks.recordset
|
|
1348
|
+
.map((r) => md.EntityByID(r.EntityID))
|
|
1349
|
+
.filter((e) => !!e);
|
|
1350
|
+
// 2) Physical table + wrapper view (create-if-absent; reuse a migration-provided table — §12).
|
|
1351
|
+
const codeName = CodeNameFromString(queryName);
|
|
1352
|
+
const tableName = `materialized_${codeName}`;
|
|
1353
|
+
const viewName = `materialized_vw${codeName}`;
|
|
1354
|
+
// CodeName-collision guard (C3): two query names that normalize to the SAME CodeName (e.g. "Sales
|
|
1355
|
+
// Report" / "Sales-Report" / "sales report") derive the same physical table. Minting the second would
|
|
1356
|
+
// reuse the first's create-if-absent table (wrong columns) and its minted entity, then register a
|
|
1357
|
+
// second MaterializedResult pointing at the SAME table — two refreshers clobbering one snapshot from
|
|
1358
|
+
// different source SQL. Refuse if the derived table is already owned by a different materialization.
|
|
1359
|
+
const tableOwners = await this.runQueryWithParams(pool, `SELECT mr.SourceType, mrq.QueryID FROM ${this.qs(coreSchema, 'MaterializedResult')} mr
|
|
1360
|
+
LEFT JOIN ${this.qs(coreSchema, 'MaterializedResultQuery')} mrq ON mrq.MaterializedResultID = mr.ID
|
|
1361
|
+
WHERE mr.SchemaName = @S AND mr.TableName = @T`, { S: coreSchema, T: tableName });
|
|
1362
|
+
const foreignOwner = tableOwners.recordset.find((row) => !(row.SourceType === 'Query' && UUIDsEqual(row.QueryID, queryId)));
|
|
1363
|
+
if (foreignOwner) {
|
|
1364
|
+
logError(` > REFUSING to materialize query "${queryName}": its derived table [${coreSchema}].[${tableName}] (from CodeName "${codeName}") is already owned by a different materialization (SourceType=${foreignOwner.SourceType}, QueryID=${foreignOwner.QueryID ?? 'n/a'}). Two sources whose names normalize to the same CodeName would clobber each other's snapshot — rename one so their CodeNames differ. Skipping.`);
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const tableSQL = this.dbProvider.generateMaterializedTableSQL(coreSchema, tableName, analysis.columns);
|
|
1368
|
+
const viewSQL = this.dbProvider.generateMaterializedWrapperViewSQL(coreSchema, viewName, tableName);
|
|
1369
|
+
await this.LogSQLAndExecute(pool, tableSQL, `Create materialized table for query "${queryName}"`, false, true);
|
|
1370
|
+
await this.LogSQLAndExecute(pool, viewSQL, `Create wrapper view for query "${queryName}"`, false, true);
|
|
1371
|
+
// 3) Mint the read-only Virtual Entity over the wrapper view (idempotent by view).
|
|
1372
|
+
const existingVE = await this.runQueryWithParams(pool, `SELECT ID FROM ${this.qs(coreSchema, 'vwEntities')} WHERE BaseView = @V AND SchemaName = @S`, { V: viewName, S: coreSchema });
|
|
1373
|
+
let generatedEntityId = existingVE.recordset[0]?.ID;
|
|
1374
|
+
if (!generatedEntityId) {
|
|
1375
|
+
try {
|
|
1376
|
+
const createRes = await pool.executeStoredProcedure(`${this.qs(coreSchema, 'spCreateVirtualEntity')}`, {
|
|
1377
|
+
Name: queryName,
|
|
1378
|
+
BaseView: viewName,
|
|
1379
|
+
SchemaName: coreSchema,
|
|
1380
|
+
PrimaryKeyFieldName: analysis.surrogateColumnName,
|
|
1381
|
+
Description: `Materialized result of query "${queryName}".`,
|
|
1382
|
+
});
|
|
1383
|
+
generatedEntityId = createRes.recordset?.[0]?.[''] || createRes.recordset?.[0]?.ID || createRes.recordset?.[0]?.Column0;
|
|
1384
|
+
if (generatedEntityId) {
|
|
1385
|
+
mintedCount++;
|
|
1386
|
+
await this.addEntityToApplicationForSchema(pool, generatedEntityId, queryName, coreSchema, currentUser);
|
|
1387
|
+
await this.addMaterializedQueryEntityPermissions(pool, generatedEntityId, queryName, resolvedSourceEntities);
|
|
1388
|
+
logStatus(` > Minted materialized entity "${queryName}" (ID: ${generatedEntityId}) over [${coreSchema}].[${viewName}]`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
catch (err) {
|
|
1392
|
+
logError(` > Failed to mint materialized entity for query "${queryName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// If the entity was neither pre-existing nor successfully minted-and-identified, we CANNOT safely link
|
|
1397
|
+
// a MaterializedResult — its GeneratedEntityID would be NULL and DataSource:'Materialized' reads could
|
|
1398
|
+
// never resolve it — and spCreateVirtualEntity may have created a physical entity we failed to read the
|
|
1399
|
+
// ID of (an unrecognized result shape). Fail loud and skip rather than silently writing a broken
|
|
1400
|
+
// MaterializedResult row; a human may need to check for an orphaned virtual entity over the view.
|
|
1401
|
+
if (!generatedEntityId) {
|
|
1402
|
+
logError(` > Could not resolve the entity ID for materialized query "${queryName}" (spCreateVirtualEntity returned an unrecognized result shape). Skipping to avoid a MaterializedResult with a NULL GeneratedEntityID; check for an orphaned virtual entity over [${coreSchema}].[${viewName}].`);
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
// 4) Upsert the MJ: Materialized Results row and its MaterializedResultQuery join row. The MR<->Query
|
|
1406
|
+
// link lives in the join table — there is no MaterializedResult.SourceQueryID or
|
|
1407
|
+
// Query.MaterializedResultID column (those direct FKs formed a circular dependency CodeGen rejects;
|
|
1408
|
+
// the join table carries the relationship with both FKs pointing outward).
|
|
1409
|
+
const existing = await this.runQueryWithParams(pool, `SELECT mr.ID FROM ${this.qs(coreSchema, 'MaterializedResult')} mr
|
|
1410
|
+
JOIN ${this.qs(coreSchema, 'MaterializedResultQuery')} mrq ON mrq.MaterializedResultID = mr.ID
|
|
1411
|
+
WHERE mr.SourceType = @T AND mrq.QueryID = @QID`, { T: 'Query', QID: queryId });
|
|
1412
|
+
let matResultId;
|
|
1413
|
+
if (existing.recordset.length > 0) {
|
|
1414
|
+
matResultId = existing.recordset[0].ID;
|
|
1415
|
+
const sqlUpd = `UPDATE ${this.qs(coreSchema, 'MaterializedResult')}
|
|
1416
|
+
SET GeneratedEntityID=${idLit(generatedEntityId)}, SchemaName='${esc(coreSchema)}', TableName='${esc(tableName)}', ViewName='${esc(viewName)}',
|
|
1417
|
+
${this.qi('ParamMode')}='${paramMode}', ${this.qi('RowFilterColumns')}=${rowFilterColumnsLit}, ${this.qi('BroadSQL')}=${broadSQLLit}, ${this.qi('ReadFilterSpec')}=${readFilterSpecLit}, ${this.qi('KeyColumns')}=${keyColumnsLit}, ${this.qi('RefreshStrategy')}='${refreshStrategy}'
|
|
1418
|
+
WHERE ID='${matResultId}'`;
|
|
1419
|
+
await this.LogSQLAndExecute(pool, sqlUpd, `Update MJ: Materialized Results for query "${queryName}"`);
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
matResultId = this.createNewUUID();
|
|
1423
|
+
const c = (n) => this.qi(n);
|
|
1424
|
+
const sqlIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResult')} (
|
|
1425
|
+
${c('ID')}, ${c('SourceType')}, ${c('GeneratedEntityID')}, ${c('SchemaName')}, ${c('TableName')}, ${c('ViewName')},
|
|
1426
|
+
${c('ParamMode')}, ${c('RowFilterColumns')}, ${c('BroadSQL')}, ${c('ReadFilterSpec')}, ${c('KeyColumns')}, ${c('RefreshStrategy')}, ${c('Status')}, ${c('__mj_CreatedAt')}, ${c('__mj_UpdatedAt')} )
|
|
1427
|
+
VALUES ( '${matResultId}', 'Query', ${idLit(generatedEntityId)}, '${esc(coreSchema)}', '${esc(tableName)}', '${esc(viewName)}',
|
|
1428
|
+
'${paramMode}', ${rowFilterColumnsLit}, ${broadSQLLit}, ${readFilterSpecLit}, ${keyColumnsLit}, '${refreshStrategy}', 'Building', ${this.utcNow()}, ${this.utcNow()} )`;
|
|
1429
|
+
await this.LogSQLAndExecute(pool, sqlIns, `Insert MJ: Materialized Results for query "${queryName}"`);
|
|
1430
|
+
// Link the new materialization to its source Query via the join table.
|
|
1431
|
+
const joinId = this.createNewUUID();
|
|
1432
|
+
const sqlJoinIns = `INSERT INTO ${this.qs(coreSchema, 'MaterializedResultQuery')} (
|
|
1433
|
+
${c('ID')}, ${c('MaterializedResultID')}, ${c('QueryID')}, ${c('__mj_CreatedAt')}, ${c('__mj_UpdatedAt')} )
|
|
1434
|
+
VALUES ( '${joinId}', '${matResultId}', '${queryId}', ${this.utcNow()}, ${this.utcNow()} )`;
|
|
1435
|
+
await this.LogSQLAndExecute(pool, sqlJoinIns, `Link MJ: Materialized Results to Query "${queryName}" via join row`);
|
|
1436
|
+
}
|
|
1437
|
+
processedCount++;
|
|
1438
|
+
}
|
|
1439
|
+
// Refresh so manageVirtualEntities (next step) syncs fields for the newly-minted entities.
|
|
1440
|
+
if (mintedCount > 0) {
|
|
1441
|
+
await md.Refresh();
|
|
1442
|
+
}
|
|
1443
|
+
return { success: true, processedCount, mintedCount };
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Phase 4 (§13/§17.2): scan active materializations and flag those whose source shape has drifted
|
|
1447
|
+
* as `DriftHold` (stop refreshing, surface for review). Runs after the entity/field re-sync, so it
|
|
1448
|
+
* compares each materialization's provenance against the CURRENT metadata. Flag-and-hold only — never
|
|
1449
|
+
* auto-rebuilds. Already-held (`DriftHold`) and `Disabled` rows are skipped.
|
|
1450
|
+
*/
|
|
1451
|
+
async detectMaterializationDrift(pool) {
|
|
1452
|
+
const coreSchema = mj_core_schema();
|
|
1453
|
+
// Gate on MaterializedResult existing — the SELECT below reads it. Absent (materialization migration not yet
|
|
1454
|
+
// applied on this DB) ⇒ no materializations exist to check for drift; skip cleanly rather than throwing and
|
|
1455
|
+
// aborting the codegen pass.
|
|
1456
|
+
if (!(await this.materializedResultTableExists(pool)))
|
|
1457
|
+
return { success: true, heldCount: 0 };
|
|
1458
|
+
// Enumerate the API-key row-filter layer BEFORE the per-row C1 re-check / leak guard consults it (see
|
|
1459
|
+
// loadAPIKeyRowFilterTargets — an unloaded cache reads 'unknown' and holds every materialization).
|
|
1460
|
+
await this.loadAPIKeyRowFilterTargets(pool);
|
|
1461
|
+
const rows = await this.runQuery(pool, `SELECT mr.ID, mr.SourceType, mr.SourceEntityID, mrq.QueryID AS SourceQueryID, mr.GeneratedEntityID, mr.SchemaName, mr.TableName
|
|
1462
|
+
FROM ${this.qs(coreSchema, 'MaterializedResult')} mr
|
|
1463
|
+
LEFT JOIN ${this.qs(coreSchema, 'MaterializedResultQuery')} mrq ON mrq.MaterializedResultID = mr.ID
|
|
1464
|
+
WHERE mr.Status NOT IN ('Disabled', 'DriftHold')`);
|
|
1465
|
+
if (rows.recordset.length === 0)
|
|
1466
|
+
return { success: true, heldCount: 0 };
|
|
1467
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1468
|
+
await md.Refresh(); // reflect this run's schema/field sync before judging drift
|
|
1469
|
+
let heldCount = 0;
|
|
1470
|
+
let failedCount = 0;
|
|
1471
|
+
// Per-row isolation: a throw while processing ONE materialization (a transient query read, a dropped
|
|
1472
|
+
// source table, a permission edge) must NOT abort reconciliation for the REST — otherwise a single
|
|
1473
|
+
// failing row indefinitely defers the C1 RLS re-check + C2 grant re-narrow for every materialization
|
|
1474
|
+
// after it (a latent fail-open). Log, count, and continue; the pass reports PARTIAL reconciliation.
|
|
1475
|
+
for (const r of rows.recordset) {
|
|
1476
|
+
try {
|
|
1477
|
+
if (await this.evaluateAndHoldDriftRow(pool, md, r, coreSchema)) {
|
|
1478
|
+
heldCount++;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (rowErr) {
|
|
1482
|
+
failedCount++;
|
|
1483
|
+
logError(` > Drift/security re-check FAILED for materialization "${r.TableName}" (ID ${r.ID}) — isolated and skipped, continuing with the rest: ${rowErr instanceof Error ? rowErr.message : String(rowErr)}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (failedCount > 0) {
|
|
1487
|
+
logError(` > detectMaterializationDrift: ${failedCount} materialization(s) failed the drift/security re-check and were skipped — reconciliation is PARTIAL this run (a source that gained RLS on a skipped row may still be granting read). Investigate the logged errors.`);
|
|
1488
|
+
}
|
|
1489
|
+
return { success: failedCount === 0, heldCount };
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Processes ONE materialization row: the C1 RLS re-check, the C2 read-grant re-narrow, the external
|
|
1493
|
+
* base-view leak guard, and generic shape/provenance drift. Returns true if the row was held. Extracted
|
|
1494
|
+
* from {@link detectMaterializationDrift} so a throw on one row can be isolated by the caller's try/catch
|
|
1495
|
+
* instead of aborting reconciliation for every remaining materialization.
|
|
1496
|
+
*/
|
|
1497
|
+
async evaluateAndHoldDriftRow(pool, md, r, coreSchema) {
|
|
1498
|
+
// RLS re-check (C1): a QUERY materialization whose source has SINCE gained a read RLS filter (or lost
|
|
1499
|
+
// its source-entity provenance) would keep serving the full unscoped snapshot to every user — the
|
|
1500
|
+
// mint-time gate cannot see a change made after minting. Re-run the same assessment here and, when it
|
|
1501
|
+
// now fails, hold the materialization AND revoke the minted entity's read access: a precomputed
|
|
1502
|
+
// snapshot cannot enforce per-user scoping, so fail closed until a human authors protection. Base-view
|
|
1503
|
+
// materializations re-apply the source entity's RLS at read time (they reuse the source entity), so
|
|
1504
|
+
// they are exempt from this check.
|
|
1505
|
+
if (r.SourceType === 'Query' && !r.SourceQueryID) {
|
|
1506
|
+
// FAIL CLOSED on LOST PROVENANCE. `SourceQueryID` comes from a LEFT JOIN on MaterializedResultQuery.
|
|
1507
|
+
// `spDeleteQuery` cascade-deletes that join row, but everything the join row protected SURVIVES: the
|
|
1508
|
+
// MaterializedResult, the minted read-only virtual entity, its EntityPermission read grants, and the
|
|
1509
|
+
// already-POPULATED materialized_<Name> table. With a NULL QueryID the C1 RLS re-check and the C2 grant
|
|
1510
|
+
// re-narrow below are both unreachable, `gatherDriftFacts`' Query branch returns all-empty arrays,
|
|
1511
|
+
// `evaluateMaterializationDrift` returns `{drift:false}`, and Status stays 'Active' — so the guard whose
|
|
1512
|
+
// whole job is to revoke read + hold could never fire again and the unscoped snapshot would keep serving
|
|
1513
|
+
// indefinitely. A query materialization that cannot name its source query has, by definition, lost the
|
|
1514
|
+
// provenance every one of those safety checks is computed from, so treat it as drift and drive it down
|
|
1515
|
+
// the established revoke-then-hold path. Over-holding is harmless (a human can relink and re-activate);
|
|
1516
|
+
// under-holding is the leak.
|
|
1517
|
+
await this.revokeReadAndHoldMaterialization(pool, r, coreSchema, 'PROVENANCE DRIFT', 'its MaterializedResultQuery link is gone (the source query was deleted), so the RLS re-check and read-grant re-narrow can no longer be computed for it');
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
if (r.SourceType === 'Query' && r.SourceQueryID) {
|
|
1521
|
+
const qe = await this.runQueryWithParams(pool, `SELECT EntityID FROM ${this.qs(coreSchema, 'QueryEntity')} WHERE QueryID = @Q`, { Q: r.SourceQueryID });
|
|
1522
|
+
const sourceEntityIds = qe.recordset.map((x) => x.EntityID);
|
|
1523
|
+
// Load the query text so the P1 under-linking guard can parse it for source tables the QueryEntity
|
|
1524
|
+
// links may have missed (see assessQuerySourceRLSSafety). Best-effort: if the read fails, the
|
|
1525
|
+
// linked-source checks still run — only a POSITIVE unlinked-RLS detection changes the verdict.
|
|
1526
|
+
const qSqlRes = await this.runQueryWithParams(pool, `SELECT SQL FROM ${this.qs(coreSchema, 'Query')} WHERE ID = @Q`, { Q: r.SourceQueryID });
|
|
1527
|
+
const driftSQL = qSqlRes.recordset?.[0]?.SQL;
|
|
1528
|
+
const rlsVerdict = this.assessQuerySourceRLSSafety(md, sourceEntityIds, driftSQL);
|
|
1529
|
+
if (!rlsVerdict.safe) {
|
|
1530
|
+
await this.revokeReadAndHoldMaterialization(pool, r, coreSchema, 'RLS DRIFT', rlsVerdict.reason ?? 'source RLS drift');
|
|
1531
|
+
return true; // already held for the leak; the shape-drift check below is moot
|
|
1532
|
+
}
|
|
1533
|
+
// Leak 2 (C2 ongoing): RLS is still safe, but a role may have LOST plain read on a source since mint.
|
|
1534
|
+
// The intersection grant is computed once at mint and never re-narrowed by the RLS check above, so
|
|
1535
|
+
// re-narrow it here to the CURRENT source-read intersection — otherwise a role that lost read on a
|
|
1536
|
+
// source keeps reading the snapshot's rows it can no longer read live. Re-narrow only (never re-grant).
|
|
1537
|
+
if (r.GeneratedEntityID) {
|
|
1538
|
+
const sourceEntities = sourceEntityIds.map((id) => md.EntityByID(id)).filter((e) => !!e);
|
|
1539
|
+
const revoked = await this.reconcileMaterializedQueryEntityReadGrants(pool, r.GeneratedEntityID, r.TableName, sourceEntities);
|
|
1540
|
+
if (revoked > 0) {
|
|
1541
|
+
logStatus(` > PERMISSION DRIFT: materialized entity "${r.TableName}" → revoked ${revoked} over-broad read grant(s) (a role can no longer read every source).`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
// SECURITY (Leak 1 drift side): an existing base-view materialization of an EXTERNAL read-RLS-protected
|
|
1546
|
+
// entity leaks (the mirror is readable via raw queries, but the entity's live reads are RLS-refused and
|
|
1547
|
+
// the read path never re-applies RLS to an external mirror). The mint gate refuses NEW ones; hold any
|
|
1548
|
+
// that already exist (or whose entity became external/RLS after minting) so refresh stops — and flag for
|
|
1549
|
+
// a human to drop the mirror. (Local base-view RLS is safe and not held.)
|
|
1550
|
+
if (r.SourceType === 'EntityBaseView' && r.SourceEntityID) {
|
|
1551
|
+
const bvEntity = md.EntityByID(r.SourceEntityID);
|
|
1552
|
+
if (bvEntity && bvEntity.ExternalDataSourceID && this.entityHasRowLevelRestriction(bvEntity)) {
|
|
1553
|
+
// EMPTY the mirror now — it holds remote rows the live path refuses under RLS, and DriftHold alone
|
|
1554
|
+
// would only stop FUTURE refreshes while leaving the already-populated rows queryable via raw SQL
|
|
1555
|
+
// over the wrapper view. Emptying (not dropping) removes the leaked data without breaking any object
|
|
1556
|
+
// dependency; the wrapper view remains but returns nothing. Then DriftHold so refresh never refills it.
|
|
1557
|
+
await this.LogSQLAndExecute(pool, `DELETE FROM ${this.qs(r.SchemaName, r.TableName)}`, `Empty external RLS base-view mirror "${r.TableName}" (leak guard — mirror exposed RLS-refused rows)`);
|
|
1558
|
+
await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag external RLS base-view materialization "${r.TableName}" as DriftHold (leak guard)`);
|
|
1559
|
+
logError(` > RLS LEAK GUARD: base-view materialization "${r.TableName}" mirrors an EXTERNAL row-restricted entity ("${bvEntity.Name}" — role RLS and/or an API-key row filter) → mirror EMPTIED + DriftHold (refresh stopped). It exposed rows the live path refuses under RLS.`);
|
|
1560
|
+
return true;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
const facts = await this.gatherDriftFacts(pool, md, r, coreSchema);
|
|
1564
|
+
const verdict = evaluateMaterializationDrift(facts);
|
|
1565
|
+
if (verdict.drift) {
|
|
1566
|
+
// Fail-closed on ANY hold of a QUERY materialization: once held, the row drops out of
|
|
1567
|
+
// detectMaterializationDrift's `Status NOT IN ('DriftHold', ...)` scan, so the C1 RLS re-check and the
|
|
1568
|
+
// C2 grant re-narrow above NEVER run for it again. A query virtual entity's reads are NOT status-gated
|
|
1569
|
+
// (they always read `materialized_vw…`), so a still-readable held snapshot whose source LATER gains RLS
|
|
1570
|
+
// (or whose role loses source read) would leak the full unscoped rows. Revoke read access on the hold to
|
|
1571
|
+
// close that window now, mirroring the RLS-drift revoke above. Base-view mats reuse the source entity
|
|
1572
|
+
// (RLS re-applied at read time via the status-gated effective base view), so they have no minted grant here.
|
|
1573
|
+
if (r.SourceType === 'Query' && r.GeneratedEntityID) {
|
|
1574
|
+
await this.revokeMaterializedEntityReadAccess(pool, r.GeneratedEntityID, r.TableName, `drift hold — ${verdict.reason ?? 'shape/provenance drift'}`);
|
|
1575
|
+
}
|
|
1576
|
+
await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag materialization "${r.TableName}" as DriftHold`);
|
|
1577
|
+
logStatus(` > DRIFT: materialization "${r.TableName}" → DriftHold — ${verdict.reason}`);
|
|
1578
|
+
return true;
|
|
1579
|
+
}
|
|
1580
|
+
return false;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* THE single rule for "which of an entity's fields get materialized" in a base-view materialization. Every
|
|
1584
|
+
* site that computes a base-view column set — the MINT (physical table + wrapper view), the DRIFT comparison,
|
|
1585
|
+
* and the runtime REFRESH mirror — must use this one predicate, or they judge each other's output as drift.
|
|
1586
|
+
*
|
|
1587
|
+
* Rule: for an EXTERNAL entity, virtual fields are MJ-computed and absent from the remote source, so the
|
|
1588
|
+
* refresh mirror only materializes non-virtual columns and the mint must match. A LOCAL base view computes its
|
|
1589
|
+
* virtual columns in the view itself, so `SELECT * FROM <baseView>` includes them and every field is kept.
|
|
1590
|
+
* (`MaterializationRefresher.rebuildFromExternalEntity` applies `!f.IsVirtual` on the external path only — the
|
|
1591
|
+
* same rule, expressed in a context where "external" is already established.)
|
|
1592
|
+
*/
|
|
1593
|
+
materializedEntityFields(entity) {
|
|
1594
|
+
return entity.Fields.filter((f) => !(entity.ExternalDataSourceID && f.IsVirtual));
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* The established fail-closed "stop serving this materialization" sequence, shared by every branch that must
|
|
1598
|
+
* take a query materialization out of service.
|
|
1599
|
+
*
|
|
1600
|
+
* Revoke read FIRST, then flag DriftHold. The revoke is the security-critical action (it closes the leak — a
|
|
1601
|
+
* snapshot serving unscoped source rows); DriftHold only stops FUTURE refreshes. Revoke-first means that if
|
|
1602
|
+
* the second statement fails, the readable window is already closed (fail-safe) — whereas DriftHold-then-revoke
|
|
1603
|
+
* would leave read OPEN if the revoke failed. A row with no minted entity has no grant row to flip; it is still
|
|
1604
|
+
* held.
|
|
1605
|
+
*/
|
|
1606
|
+
async revokeReadAndHoldMaterialization(pool, r, coreSchema, label, reason) {
|
|
1607
|
+
if (r.GeneratedEntityID) {
|
|
1608
|
+
await this.revokeMaterializedEntityReadAccess(pool, r.GeneratedEntityID, r.TableName, reason);
|
|
1609
|
+
}
|
|
1610
|
+
await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(coreSchema, 'MaterializedResult')} SET ${this.qi('Status')}='DriftHold' WHERE ID='${r.ID}'`, `Flag materialization "${r.TableName}" as DriftHold (${label.toLowerCase()})`);
|
|
1611
|
+
logError(` > ${label}: materialization "${r.TableName}" → read access revoked + DriftHold — ${reason}`);
|
|
1612
|
+
}
|
|
1613
|
+
/** Gathers the drift-relevant existence facts for one materialization against current metadata. */
|
|
1614
|
+
async gatherDriftFacts(pool, md, r, coreSchema) {
|
|
1615
|
+
if (r.SourceType === 'EntityBaseView') {
|
|
1616
|
+
const entity = r.SourceEntityID ? md.EntityByID(r.SourceEntityID) : undefined;
|
|
1617
|
+
if (!entity) {
|
|
1618
|
+
return { sourceType: 'EntityBaseView', baseView: { sourceEntityExists: false, currentEntityFields: [], materializedColumns: [] } };
|
|
1619
|
+
}
|
|
1620
|
+
const materializedColumns = await this.getMaterializedTableColumns(pool, r.SchemaName, r.TableName);
|
|
1621
|
+
return {
|
|
1622
|
+
sourceType: 'EntityBaseView',
|
|
1623
|
+
// MUST use the same field-exclusion rule the MINT and the REFRESH use ({@link materializedEntityFields}).
|
|
1624
|
+
// Comparing the UNFILTERED field list against the mirror's columns made every external entity with an
|
|
1625
|
+
// IsVirtual field look like it had drifted the very first run after minting (added=[<virtual fields>])
|
|
1626
|
+
// → Status='DriftHold' → permanent live-only fallback, and DriftHold rows are excluded from the sweep,
|
|
1627
|
+
// so it could never recover. The comparison has to be apples-to-apples with what was actually built.
|
|
1628
|
+
baseView: { sourceEntityExists: true, currentEntityFields: this.materializedEntityFields(entity).map((f) => f.Name), materializedColumns },
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
// Query case — provenance via QueryEntity / QueryField / QueryDependency, plus output-shape facts.
|
|
1632
|
+
const missingSourceEntities = [];
|
|
1633
|
+
const missingSourceFields = [];
|
|
1634
|
+
const missingComposedQueries = [];
|
|
1635
|
+
// Output-shape facts (§13 drift): the query's CURRENT declared output columns (ALL fields) vs the
|
|
1636
|
+
// snapshot table's actual DATA columns. Detects a SELECT-list edit on a create-if-absent table that
|
|
1637
|
+
// provenance existence checks can't see. Both empty-tolerant downstream.
|
|
1638
|
+
let currentOutputColumns = [];
|
|
1639
|
+
let materializedColumns = [];
|
|
1640
|
+
if (r.SourceQueryID) {
|
|
1641
|
+
const qid = r.SourceQueryID;
|
|
1642
|
+
const qe = await this.runQueryWithParams(pool, `SELECT EntityID FROM ${this.qs(coreSchema, 'QueryEntity')} WHERE QueryID = @Q`, { Q: qid });
|
|
1643
|
+
for (const row of qe.recordset) {
|
|
1644
|
+
if (!md.EntityByID(row.EntityID))
|
|
1645
|
+
missingSourceEntities.push(row.EntityID);
|
|
1646
|
+
}
|
|
1647
|
+
// ONE QueryField read serves BOTH needs: the output-shape column set (ALL Names) and the provenance
|
|
1648
|
+
// field check (rows whose SourceEntityID+SourceFieldName are both known — only those are judged, to
|
|
1649
|
+
// avoid false positives from incomplete/LLM-inferred provenance; filtered in JS below).
|
|
1650
|
+
const qf = await this.runQueryWithParams(pool, `SELECT Name, SourceEntityID, SourceFieldName FROM ${this.qs(coreSchema, 'QueryField')} WHERE QueryID = @Q`, { Q: qid });
|
|
1651
|
+
// Both sides of the output-shape comparison must exclude the SAME system columns: the synthetic
|
|
1652
|
+
// surrogate PK and any __mj_* columns are always present in the materialized table but are stripped
|
|
1653
|
+
// from materializedColumns below. A query that legitimately PROJECTS a __mj_* column (e.g. SELECT
|
|
1654
|
+
// __mj_UpdatedAt) would otherwise appear only in currentOutputColumns → a phantom "added" drift that
|
|
1655
|
+
// false-flags DriftHold. Applying the identical filter here keeps the comparison apples-to-apples.
|
|
1656
|
+
const surrogate = MATERIALIZATION_SURROGATE_COLUMN.toLowerCase();
|
|
1657
|
+
const isOutputShapeColumn = (name) => {
|
|
1658
|
+
const lc = name.trim().toLowerCase();
|
|
1659
|
+
return lc !== surrogate && !lc.startsWith('__mj_');
|
|
1660
|
+
};
|
|
1661
|
+
currentOutputColumns = qf.recordset.map((row) => String(row.Name)).filter(isOutputShapeColumn);
|
|
1662
|
+
for (const row of qf.recordset) {
|
|
1663
|
+
if (row.SourceEntityID == null || row.SourceFieldName == null)
|
|
1664
|
+
continue;
|
|
1665
|
+
const ent = md.EntityByID(row.SourceEntityID);
|
|
1666
|
+
const fieldName = String(row.SourceFieldName);
|
|
1667
|
+
if (!ent) {
|
|
1668
|
+
missingSourceFields.push(`${row.SourceEntityID}.${fieldName}`);
|
|
1669
|
+
}
|
|
1670
|
+
else if (!ent.Fields.find((f) => f.Name.trim().toLowerCase() === fieldName.trim().toLowerCase())) {
|
|
1671
|
+
missingSourceFields.push(`${ent.Name}.${fieldName}`);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
const qd = await this.runQueryWithParams(pool, `SELECT DependsOnQueryID FROM ${this.qs(coreSchema, 'QueryDependency')} WHERE QueryID = @Q`, { Q: qid });
|
|
1675
|
+
const depIds = qd.recordset.map((row) => row.DependsOnQueryID).filter((d) => !!d);
|
|
1676
|
+
if (depIds.length > 0) {
|
|
1677
|
+
// ONE set-based existence check instead of a SELECT per dependency (was an N+1 cascade — K
|
|
1678
|
+
// round trips per materialization). DependsOnQueryID values are DB-sourced UUIDs; single-quote
|
|
1679
|
+
// escaped defensively. Case-insensitive membership handles SS-uppercase vs stored casing.
|
|
1680
|
+
const inList = depIds.map((d) => `'${d.replace(/'/g, "''")}'`).join(', ');
|
|
1681
|
+
const existing = await this.runQuery(pool, `SELECT ID FROM ${this.qs(coreSchema, 'Query')} WHERE ID IN (${inList})`);
|
|
1682
|
+
const existingSet = new Set(existing.recordset.map((row) => String(row.ID).toLowerCase()));
|
|
1683
|
+
for (const d of depIds) {
|
|
1684
|
+
if (!existingSet.has(d.toLowerCase()))
|
|
1685
|
+
missingComposedQueries.push(d);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
const rawCols = await this.getMaterializedTableColumns(pool, r.SchemaName, r.TableName);
|
|
1689
|
+
// Same exclusion as currentOutputColumns (see isOutputShapeColumn above) — surrogate + __mj_* columns
|
|
1690
|
+
// are not part of the query's declared output shape.
|
|
1691
|
+
materializedColumns = rawCols.filter(isOutputShapeColumn);
|
|
1692
|
+
}
|
|
1693
|
+
return { sourceType: 'Query', query: { missingSourceEntities, missingSourceFields, missingComposedQueries, currentOutputColumns, materializedColumns } };
|
|
1694
|
+
}
|
|
1695
|
+
/** Actual column names of a materialized table (via INFORMATION_SCHEMA), for drift comparison. */
|
|
1696
|
+
async getMaterializedTableColumns(pool, schema, table) {
|
|
1697
|
+
const esc = (s) => s.replace(/'/g, "''");
|
|
1698
|
+
const res = await this.runQuery(pool, `SELECT COLUMN_NAME AS cn FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='${esc(schema)}' AND TABLE_NAME='${esc(table)}' ORDER BY ORDINAL_POSITION`);
|
|
1699
|
+
return res.recordset.map((row) => row.cn);
|
|
1700
|
+
}
|
|
954
1701
|
/**
|
|
955
1702
|
* Derives an entity name from a view name by removing common prefixes (vw, v_)
|
|
956
1703
|
* and converting to a human-friendly format.
|
|
@@ -1108,6 +1855,13 @@ export class ManageMetadataBase {
|
|
|
1108
1855
|
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
1109
1856
|
await md.Refresh();
|
|
1110
1857
|
}
|
|
1858
|
+
// Config-driven query materialization — mint read-only Virtual Entities over materialized
|
|
1859
|
+
// query results. Runs BEFORE manageVirtualEntities so the minted entities' fields get synced
|
|
1860
|
+
// from their wrapper views in this same run (plan §4.2). Internally refreshes metadata when it mints.
|
|
1861
|
+
const queryMatResult = await this.processQueryMaterializations(pool, currentUser);
|
|
1862
|
+
if (queryMatResult.processedCount > 0) {
|
|
1863
|
+
logStatus(` > Query materialization: processed ${queryMatResult.processedCount} quer${queryMatResult.processedCount === 1 ? 'y' : 'ies'} (${queryMatResult.mintedCount} new entit${queryMatResult.mintedCount === 1 ? 'y' : 'ies'})`);
|
|
1864
|
+
}
|
|
1111
1865
|
const veResult = await this.manageVirtualEntities(pool);
|
|
1112
1866
|
if (!veResult.success) {
|
|
1113
1867
|
logError(' Error managing virtual entities');
|
|
@@ -1158,6 +1912,21 @@ export class ManageMetadataBase {
|
|
|
1158
1912
|
if (organicKeyResult.createdCount > 0 || organicKeyResult.updatedCount > 0) {
|
|
1159
1913
|
logStatus(` > Organic keys: ${organicKeyResult.createdCount} created, ${organicKeyResult.updatedCount} updated from config`);
|
|
1160
1914
|
}
|
|
1915
|
+
// Config-driven base-view materialization — emit the physical table + wrapper view and
|
|
1916
|
+
// attach an "MJ: Materialized Results" row to the existing entity. Runs AFTER entity fields
|
|
1917
|
+
// are managed so the snapshot shape reflects the current base view (plan §4.1).
|
|
1918
|
+
const baseViewMatResult = await this.processBaseViewMaterializations(pool);
|
|
1919
|
+
if (baseViewMatResult.processedCount > 0) {
|
|
1920
|
+
logStatus(` > Base-view materialization: processed ${baseViewMatResult.processedCount} declaration${baseViewMatResult.processedCount === 1 ? '' : 's'} from config`);
|
|
1921
|
+
}
|
|
1922
|
+
// Phase 4 (§13/§17.2): flag materializations whose source shape drifted (source entity/field
|
|
1923
|
+
// dropped/renamed, or a composed inner query removed) as DriftHold and stop refreshing them —
|
|
1924
|
+
// flag-and-hold, never silent auto-rebuild. Runs after the entity/field re-sync so it sees the
|
|
1925
|
+
// current schema.
|
|
1926
|
+
const driftResult = await this.detectMaterializationDrift(pool);
|
|
1927
|
+
if (driftResult.heldCount > 0) {
|
|
1928
|
+
logStatus(` > Materialization drift: held ${driftResult.heldCount} materialization${driftResult.heldCount === 1 ? '' : 's'} for review (Status=DriftHold)`);
|
|
1929
|
+
}
|
|
1161
1930
|
start = new Date();
|
|
1162
1931
|
logStatus(' Syncing schema info from database...');
|
|
1163
1932
|
if (!await this.updateSchemaInfoFromDatabase(pool, excludeSchemas)) {
|
|
@@ -1255,19 +2024,56 @@ export class ManageMetadataBase {
|
|
|
1255
2024
|
}
|
|
1256
2025
|
return { success: bSuccess, anyUpdates: anyUpdates };
|
|
1257
2026
|
}
|
|
2027
|
+
/**
|
|
2028
|
+
* External-data-source analogue of {@link manageVirtualEntities}. For each entity backed by an
|
|
2029
|
+
* external data source, introspect the REMOTE schema (via the EDS router resolved through the
|
|
2030
|
+
* ClassFactory) and sync its `EntityField` rows to match — the remote equivalent of reading the
|
|
2031
|
+
* local INFORMATION_SCHEMA for a virtual/view entity. No-op when there are no external entities.
|
|
2032
|
+
*/
|
|
2033
|
+
/**
|
|
2034
|
+
* Whether the current database's `Entity` table has the `ExternalDataSourceID` column. External
|
|
2035
|
+
* Data Sources ship as a SQL Server migration only, so on any database/schema that predates it
|
|
2036
|
+
* (PostgreSQL today) the column is absent — and ANY raw SQL referencing it throws
|
|
2037
|
+
* "column does not exist" and aborts the entire CodeGen run, even for a pure MJ-DB schema with no
|
|
2038
|
+
* external entities. Callers gate EDS-specific queries on this so CodeGen stays green everywhere
|
|
2039
|
+
* and auto-activates once the column lands on other platforms (mirrors the PG stored-proc guard in
|
|
2040
|
+
* metadataSupportObjects.ts). Cross-platform via INFORMATION_SCHEMA; cached for the run (the schema
|
|
2041
|
+
* cannot change mid-run).
|
|
2042
|
+
*/
|
|
1258
2043
|
async entityHasExternalDataSourceColumn(pool) {
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
2044
|
+
// Check the VIEW the gated queries actually read (vwEntities), not the base Entity table — a
|
|
2045
|
+
// schema where the table has the column but the view wasn't refreshed to expose it would still
|
|
2046
|
+
// throw. (Matches the PG guard in metadataSupportObjects.ts, which also checks the view.)
|
|
2047
|
+
return await this.columnExistsInCoreSchema(pool, 'vwEntities', 'ExternalDataSourceID');
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Whether the current database's `Query` table has the `ExternalDataSourceID` column. Same rationale as
|
|
2051
|
+
* {@link entityHasExternalDataSourceColumn}: External Data Sources ship as a SQL-Server-only migration, so
|
|
2052
|
+
* on a DB without it (PostgreSQL today) the column is absent and any raw SQL referencing it aborts the
|
|
2053
|
+
* CodeGen run. processQueryMaterializations gates its ExternalDataSourceID reference on this. Cached per run.
|
|
2054
|
+
*/
|
|
2055
|
+
async queryHasExternalDataSourceColumn(pool) {
|
|
2056
|
+
return await this.columnExistsInCoreSchema(pool, 'Query', 'ExternalDataSourceID');
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* True if the Query table has the IsMaterialized column (added by the materialization Foundation migration).
|
|
2060
|
+
* processQueryMaterializations gates on this so codegen doesn't throw on a DB where that migration hasn't run
|
|
2061
|
+
* yet (e.g. the PostgreSQL parallel world's object-availability lag) — same defensive pattern as
|
|
2062
|
+
* queryHasExternalDataSourceColumn. Cached per run.
|
|
2063
|
+
*/
|
|
2064
|
+
async queryHasIsMaterializedColumn(pool) {
|
|
2065
|
+
return await this.columnExistsInCoreSchema(pool, 'Query', 'IsMaterialized');
|
|
2066
|
+
}
|
|
2067
|
+
async materializedResultTableExists(pool) {
|
|
2068
|
+
if (this._materializedResultTableExists === null) {
|
|
2069
|
+
const sql = `SELECT COUNT(*) AS TblExists FROM INFORMATION_SCHEMA.TABLES ` +
|
|
2070
|
+
`WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = 'MaterializedResult'`;
|
|
1265
2071
|
const result = await this.runQuery(pool, sql);
|
|
1266
2072
|
const row = (result.recordset?.[0] ?? {});
|
|
1267
|
-
const cnt = row.
|
|
1268
|
-
this.
|
|
2073
|
+
const cnt = row.TblExists ?? row.tblexists ?? 0;
|
|
2074
|
+
this._materializedResultTableExists = Number(cnt) > 0;
|
|
1269
2075
|
}
|
|
1270
|
-
return this.
|
|
2076
|
+
return this._materializedResultTableExists;
|
|
1271
2077
|
}
|
|
1272
2078
|
async manageExternalEntities(pool, currentUser) {
|
|
1273
2079
|
let bSuccess = true;
|
|
@@ -2070,9 +2876,12 @@ export class ManageMetadataBase {
|
|
|
2070
2876
|
}
|
|
2071
2877
|
// Check the DATABASE for existing field record — in-memory metadata may be stale
|
|
2072
2878
|
// (e.g. createNewEntityFieldsFromSchema may have already added this field from the view)
|
|
2073
|
-
|
|
2879
|
+
// Identifiers are quoted via qi(): __mj.EntityField's columns are mixed-case, and an
|
|
2880
|
+
// unquoted reference folds to lower case on PostgreSQL — `column "length" does not exist`.
|
|
2881
|
+
// SQL Server's case-insensitive resolution hid this for as long as IS-A ran only there.
|
|
2882
|
+
const existsResult = await this.runQueryWithParams(pool, `SELECT ${this.qi('ID')}, ${this.qi('IsVirtual')}, ${this.qi('Type')}, ${this.qi('Length')}, ${this.qi('Precision')}, ${this.qi('Scale')}, ${this.qi('AllowsNull')}, ${this.qi('AllowUpdateAPI')}
|
|
2074
2883
|
FROM ${this.qs(mj_core_schema(), 'EntityField')}
|
|
2075
|
-
WHERE EntityID = @EntityID AND Name = @FieldName`, { 'EntityID': childEntity.ID, 'FieldName': parentField.Name });
|
|
2884
|
+
WHERE ${this.qi('EntityID')} = @EntityID AND ${this.qi('Name')} = @FieldName`, { 'EntityID': childEntity.ID, 'FieldName': parentField.Name });
|
|
2076
2885
|
if (existsResult.recordset.length > 0) {
|
|
2077
2886
|
// Field already exists — update it to ensure it's marked as a virtual IS-A field
|
|
2078
2887
|
const existingRow = existsResult.recordset[0];
|
|
@@ -2085,14 +2894,14 @@ export class ManageMetadataBase {
|
|
|
2085
2894
|
!existingRow.AllowUpdateAPI;
|
|
2086
2895
|
if (needsUpdate) {
|
|
2087
2896
|
const sqlUpdate = `UPDATE ${this.qs(mj_core_schema(), 'EntityField')}
|
|
2088
|
-
SET IsVirtual=${this.boolLit(true)},
|
|
2089
|
-
Type='${parentField.Type}',
|
|
2090
|
-
Length=${parentField.Length},
|
|
2091
|
-
Precision=${parentField.Precision},
|
|
2092
|
-
Scale=${parentField.Scale},
|
|
2093
|
-
AllowsNull=${this.boolLit(parentField.AllowsNull)},
|
|
2094
|
-
AllowUpdateAPI=${this.boolLit(true)}
|
|
2095
|
-
WHERE ID='${existingRow.ID}'`;
|
|
2897
|
+
SET ${this.qi('IsVirtual')}=${this.boolLit(true)},
|
|
2898
|
+
${this.qi('Type')}='${parentField.Type}',
|
|
2899
|
+
${this.qi('Length')}=${parentField.Length},
|
|
2900
|
+
${this.qi('Precision')}=${parentField.Precision},
|
|
2901
|
+
${this.qi('Scale')}=${parentField.Scale},
|
|
2902
|
+
${this.qi('AllowsNull')}=${this.boolLit(parentField.AllowsNull)},
|
|
2903
|
+
${this.qi('AllowUpdateAPI')}=${this.boolLit(true)}
|
|
2904
|
+
WHERE ${this.qi('ID')}='${existingRow.ID}'`;
|
|
2096
2905
|
await this.LogSQLAndExecute(pool, sqlUpdate, `Update IS-A parent field ${parentField.Name} on ${childEntity.Name}`);
|
|
2097
2906
|
bUpdated = true;
|
|
2098
2907
|
}
|
|
@@ -3919,7 +4728,27 @@ export class ManageMetadataBase {
|
|
|
3919
4728
|
}
|
|
3920
4729
|
async createNewEntities(pool, currentUser) {
|
|
3921
4730
|
try {
|
|
3922
|
-
|
|
4731
|
+
// Exclude the PHYSICAL materialization tables (materialized_<Name>) from new-entity auto-creation.
|
|
4732
|
+
// They are internal snapshot storage, surfaced ONLY through the read-only virtual entity minted over
|
|
4733
|
+
// their wrapper view. On any codegen re-run after a materialization exists, this scan would otherwise
|
|
4734
|
+
// find the physical table (which has no entity of its own) and auto-mint a spurious CRUD entity over
|
|
4735
|
+
// it — user-visible AND editable, letting someone mutate the snapshot the refresher overwrites. The
|
|
4736
|
+
// wrapper view is already excluded (it carries the minted entity, so EntityID IS NOT NULL). The outer
|
|
4737
|
+
// view is aliased `t` so the correlated MaterializedResult subquery can't resolve columns ambiguously.
|
|
4738
|
+
const coreSchema = mj_core_schema();
|
|
4739
|
+
// Gate the MaterializedResult reference on the table actually existing. Referencing it unconditionally
|
|
4740
|
+
// would throw — breaking new-entity creation for EVERY entity — on any DB where MaterializedResult
|
|
4741
|
+
// isn't present yet (e.g. the PostgreSQL parallel world's object-availability lag). If it's absent we
|
|
4742
|
+
// simply skip the exclusion (the physical materialized table can't exist without it either, so nothing
|
|
4743
|
+
// to exclude). Uses the shared cached helper so all three materialization-schema gates stay consistent.
|
|
4744
|
+
const matResultExists = await this.materializedResultTableExists(pool);
|
|
4745
|
+
const matExclusion = matResultExists
|
|
4746
|
+
? `AND NOT EXISTS (SELECT 1 FROM ${this.qs(coreSchema, 'MaterializedResult')} mr WHERE mr.${this.qi('SchemaName')} = t.${this.qi('SchemaName')} AND mr.${this.qi('TableName')} = t.${this.qi('TableName')}) `
|
|
4747
|
+
: '';
|
|
4748
|
+
const sSQL = `SELECT t.* FROM ${this.qs(coreSchema, 'vwSQLTablesAndEntities')} t `
|
|
4749
|
+
+ `WHERE t.${this.qi('EntityID')} IS NULL `
|
|
4750
|
+
+ matExclusion
|
|
4751
|
+
+ this.createExcludeTablesAndSchemasFilter('t.');
|
|
3923
4752
|
const newEntitiesResult = await this.runQuery(pool, sSQL);
|
|
3924
4753
|
const newEntities = newEntitiesResult.recordset;
|
|
3925
4754
|
if (newEntities && newEntities.length > 0) {
|
|
@@ -4416,6 +5245,308 @@ export class ManageMetadataBase {
|
|
|
4416
5245
|
}
|
|
4417
5246
|
}
|
|
4418
5247
|
}
|
|
5248
|
+
/**
|
|
5249
|
+
* RLS-safety assessment for a QUERY materialization, shared by the mint-time gate and the per-run drift
|
|
5250
|
+
* re-check (plan §6.2 / §10). A materialized query entity does NOT inherit its source entities' row-level
|
|
5251
|
+
* security, so it is only safe to serve an unscoped snapshot when we can PROVE no source is RLS-protected.
|
|
5252
|
+
* Fails closed (returns `{ safe:false }`) on three conditions, in order of decreasing severity of what we
|
|
5253
|
+
* cannot prove:
|
|
5254
|
+
* 1. **No provenance** — zero linked source entities (raw SQL / un-analyzed query): we cannot inspect any
|
|
5255
|
+
* source's RLS, so we cannot prove safety.
|
|
5256
|
+
* 2. **Unresolvable link** — a linked EntityID that doesn't resolve to a known `EntityInfo`: if it secretly
|
|
5257
|
+
* carried a read RLS filter we'd never see it.
|
|
5258
|
+
* 3. **Row-restricted source** — any resolved source protected by role RLS *or* an API-key row filter (see
|
|
5259
|
+
* {@link entityHasRowLevelRestriction}; the API-key layer matters because such an entity carries no
|
|
5260
|
+
* `ReadRLSFilterID`, and the minted entity's NEW EntityID would not match the key's EntityID binding).
|
|
5261
|
+
* Over-restriction here is harmless (the query stays live-only, or an existing materialization is held); the
|
|
5262
|
+
* reverse — serving a protected source's rows unscoped — is the leak this guard exists to prevent.
|
|
5263
|
+
*/
|
|
5264
|
+
assessQuerySourceRLSSafety(md, sourceEntityIds, sql) {
|
|
5265
|
+
if (sourceEntityIds.length === 0) {
|
|
5266
|
+
return { safe: false, reason: 'no source-entity provenance is recorded (its QueryEntity links are empty), so RLS-safety cannot be verified' };
|
|
5267
|
+
}
|
|
5268
|
+
const resolved = sourceEntityIds.map((id) => ({ id, entity: md.EntityByID(id) }));
|
|
5269
|
+
const unresolved = resolved.filter((x) => !x.entity);
|
|
5270
|
+
if (unresolved.length > 0) {
|
|
5271
|
+
return { safe: false, reason: `${unresolved.length} source-entity link(s) [${unresolved.map((u) => u.id).join(', ')}] do not resolve to a known entity, so their RLS status can't be verified` };
|
|
5272
|
+
}
|
|
5273
|
+
const rlsProtected = resolved
|
|
5274
|
+
.map((x) => x.entity)
|
|
5275
|
+
.filter((e) => this.entityHasRowLevelRestriction(e));
|
|
5276
|
+
if (rlsProtected.length > 0) {
|
|
5277
|
+
const names = rlsProtected.map((e) => `"${e.Name}"`).join(', ');
|
|
5278
|
+
return { safe: false, reason: `source entit${rlsProtected.length === 1 ? 'y' : 'ies'} ${names} ${rlsProtected.length === 1 ? 'is' : 'are'} read-RLS-protected (role RLS and/or an API-key row filter), and a materialized query entity does NOT inherit source row restrictions (plan §6.2)` };
|
|
5279
|
+
}
|
|
5280
|
+
// P1 hardening — catch RLS sources the LINKER MISSED. The checks above inspect only the LINKED sources
|
|
5281
|
+
// (vwQueryEntities); a source reached via a wrapping view / CTE / function / aliased columns that
|
|
5282
|
+
// query-analysis under-linked would be invisible to them. Parse the query SQL directly, map each table ref
|
|
5283
|
+
// to an entity, and fail closed if a referenced entity is read-RLS-protected but NOT in the linked set —
|
|
5284
|
+
// the precise leak (an unverified RLS source). An unlinked NON-RLS table is not a leak, so it doesn't trip
|
|
5285
|
+
// this (avoids over-refusing legitimate materializations). Parse failure is ignored (the linked-only checks
|
|
5286
|
+
// above still stand); only a POSITIVE detection of an unlinked RLS source refuses.
|
|
5287
|
+
if (sql && sql.trim().length > 0) {
|
|
5288
|
+
const linkedSet = new Set(sourceEntityIds.map((id) => id.trim().toLowerCase()));
|
|
5289
|
+
let refs = [];
|
|
5290
|
+
try {
|
|
5291
|
+
refs = SQLParser.ExtractTableRefs(sql, this.dialect) ?? [];
|
|
5292
|
+
}
|
|
5293
|
+
catch {
|
|
5294
|
+
refs = [];
|
|
5295
|
+
}
|
|
5296
|
+
// CTE self-references are emitted by ExtractTableRefs as unqualified (dbo-defaulted) refs, but they are
|
|
5297
|
+
// NOT base-table sources. Since findEntityByBaseObject now falls back to a schema-agnostic name match for
|
|
5298
|
+
// unqualified refs (to catch under-linked __mj/app-schema sources), a CTE that happens to share an
|
|
5299
|
+
// entity's base-table name would otherwise be misread as an under-linked source and over-refuse. Collect
|
|
5300
|
+
// the CTE names and skip any unqualified ref that matches one. (Parse failure → no exclusion; the guard
|
|
5301
|
+
// still fails closed, only slightly more conservatively.)
|
|
5302
|
+
const cteNames = new Set();
|
|
5303
|
+
try {
|
|
5304
|
+
// Extract each CTE's name from its "name AS (...)" definition. Handle all three quotings SQLParser can
|
|
5305
|
+
// emit ([bracket], "double-quote", bare) plus an optional column list — mirroring the parser's own CTE
|
|
5306
|
+
// header regex. A missed name only means a (safe) over-refusal, but bracket names are the common T-SQL
|
|
5307
|
+
// form, so cover them.
|
|
5308
|
+
for (const def of SQLParser.ExtractCTEs(sql, this.dialect)?.CTEDefinitions ?? []) {
|
|
5309
|
+
const m = /^\s*(?:\[([^\]]+)\]|"([^"]+)"|([A-Za-z_][\w$]*))\s*(?:\([^)]*\))?\s+AS\b/i.exec(def);
|
|
5310
|
+
const name = m?.[1] ?? m?.[2] ?? m?.[3];
|
|
5311
|
+
if (name)
|
|
5312
|
+
cteNames.add(name.trim().toLowerCase());
|
|
5313
|
+
}
|
|
5314
|
+
}
|
|
5315
|
+
catch { /* no CTE exclusion available — guard stays fail-closed */ }
|
|
5316
|
+
for (const ref of refs) {
|
|
5317
|
+
if (!ref.TableName)
|
|
5318
|
+
continue;
|
|
5319
|
+
const refSchema = (ref.SchemaName ?? '').trim().toLowerCase();
|
|
5320
|
+
// Skip CTE self-references (only unqualified / dbo-defaulted refs can be a CTE name).
|
|
5321
|
+
if ((refSchema === '' || refSchema === 'dbo') && cteNames.has(ref.TableName.trim().toLowerCase()))
|
|
5322
|
+
continue;
|
|
5323
|
+
// Refuse ANY under-linked entity source, not only RLS-protected ones. Both the RLS-safety check above
|
|
5324
|
+
// AND the read-grant role intersection (addMaterializedQueryEntityPermissions) are computed over the
|
|
5325
|
+
// LINKED set only, so a source query-analysis did NOT link is invisible to both: an RLS source would
|
|
5326
|
+
// leak its rows unscoped, and a merely CanRead-restricted (non-RLS) source would let the minted
|
|
5327
|
+
// entity's read grant exceed "can read every source" — a privilege escalation. A parsed ref maps to an
|
|
5328
|
+
// entity when it IS that entity's base view/table; derived-table aliases map to nothing, and CTE
|
|
5329
|
+
// self-references are excluded above, so a mapped-but-unlinked source is genuine under-linking and
|
|
5330
|
+
// refusing is correct; over-refusal is harmless (the query stays live-only — link the source via full
|
|
5331
|
+
// query analysis, or use a base-view materialization which inherits source read access).
|
|
5332
|
+
// Consider ALL candidate entities the ref can map to and fail closed if ANY is unlinked: an unqualified
|
|
5333
|
+
// ref is schema-ambiguous (the parser collapses it to 'dbo'), so a same-base-name entity in another
|
|
5334
|
+
// schema could be the true referent — picking only the first match could silently pass an unlinked one.
|
|
5335
|
+
const candidates = this.findEntitiesByBaseObject(md, ref.SchemaName, ref.TableName);
|
|
5336
|
+
const unlinked = candidates.find((e) => !linkedSet.has(e.ID.trim().toLowerCase()));
|
|
5337
|
+
if (unlinked) {
|
|
5338
|
+
const rlsNote = this.entityHasRowLevelRestriction(unlinked) ? ' row-restricted (role RLS and/or an API-key row filter)' : ' read-restricted';
|
|
5339
|
+
return { safe: false, reason: `query references${rlsNote} source "${unlinked.Name}" (${ref.SchemaName ?? ''}.${ref.TableName}) that query-analysis did NOT link — an unscoped snapshot cannot prove its per-source read access is honored (P1 under-linking guard)` };
|
|
5340
|
+
}
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
return { safe: true };
|
|
5344
|
+
}
|
|
5345
|
+
/** Maps a physical (schema, table/view) reference to the MJ entities backed by it — by BaseView or BaseTable,
|
|
5346
|
+
* case/whitespace-insensitive. Returns ALL candidates so the P1 guard can fail closed on an AMBIGUOUS ref.
|
|
5347
|
+
* Resolution: an explicit, non-default schema yields only that schema's exact matches; an empty schema OR the
|
|
5348
|
+
* parser-default `'dbo'` — which {@link SQLParser.ExtractTableRefs} assigns to every UNQUALIFIED ref, while MJ
|
|
5349
|
+
* core/app entities live in `__mj`/app schemas (never `dbo`) — is treated as unqualified and yields every
|
|
5350
|
+
* schema-agnostic name match. That way an unqualified reference to a `__mj`/app-schema entity is still caught,
|
|
5351
|
+
* and a same-base-name collision across schemas surfaces every candidate rather than just the first (so the
|
|
5352
|
+
* guard refuses if any of them is under-linked). Used by the P1 under-linking guard. */
|
|
5353
|
+
findEntitiesByBaseObject(md, schema, table) {
|
|
5354
|
+
const t = table.trim().toLowerCase();
|
|
5355
|
+
const s = (schema ?? '').trim().toLowerCase();
|
|
5356
|
+
const nameMatch = (e) => {
|
|
5357
|
+
const bv = (e.BaseView ?? '').trim().toLowerCase();
|
|
5358
|
+
const bt = (e.BaseTable ?? '').trim().toLowerCase();
|
|
5359
|
+
return bv === t || bt === t;
|
|
5360
|
+
};
|
|
5361
|
+
if (s && s !== 'dbo') {
|
|
5362
|
+
// Explicit, non-default schema → only that schema's entity qualifies (no cross-schema ambiguity).
|
|
5363
|
+
return md.Entities.filter((e) => (e.SchemaName ?? '').trim().toLowerCase() === s && nameMatch(e));
|
|
5364
|
+
}
|
|
5365
|
+
// Empty schema / parser-default 'dbo' (i.e. UNQUALIFIED) → every name match across schemas is a candidate.
|
|
5366
|
+
return md.Entities.filter(nameMatch);
|
|
5367
|
+
}
|
|
5368
|
+
/**
|
|
5369
|
+
* Grants read permissions to a newly-minted materialized QUERY entity scoped to the INTERSECTION of source
|
|
5370
|
+
* read access (C2). A precomputed snapshot has no per-row scoping, so a role may read it only if it can read
|
|
5371
|
+
* EVERY source entity — otherwise a user who cannot read a source could read its rows through the snapshot.
|
|
5372
|
+
* Starts from the same config-default role list as {@link addDefaultPermissionsForEntity} (never grants a
|
|
5373
|
+
* role the defaults wouldn't) and drops any role that lacks explicit read on every source. Always read-only
|
|
5374
|
+
* (the entity is a virtual entity with no CRUD sprocs). If the intersection is empty the entity receives no
|
|
5375
|
+
* role-based read grants — the fail-closed direction (§10); a human can grant read after review.
|
|
5376
|
+
*/
|
|
5377
|
+
async addMaterializedQueryEntityPermissions(pool, entityId, entityName, sourceEntities) {
|
|
5378
|
+
if (!configInfo.newEntityDefaults.PermissionDefaults?.AutoAddPermissionsForNewEntities) {
|
|
5379
|
+
return;
|
|
5380
|
+
}
|
|
5381
|
+
// Fail-closed (C2): with no resolved source entities we cannot prove any role can read EVERY source, so
|
|
5382
|
+
// grant nothing. `sourceEntities.every(...)` is vacuously true on an empty array and would otherwise grant
|
|
5383
|
+
// read to every default role — the exact "read the snapshot without read on a source" leak this method
|
|
5384
|
+
// exists to prevent. Callers already refuse on empty provenance, but guard here too so the security
|
|
5385
|
+
// invariant holds in isolation, not only by a distant caller's gate.
|
|
5386
|
+
if (sourceEntities.length === 0) {
|
|
5387
|
+
logStatus(` > Materialized entity "${entityName}": no resolved source entities — granting no role-based read (fail-closed).`);
|
|
5388
|
+
return;
|
|
5389
|
+
}
|
|
5390
|
+
const md = new Metadata(); // global-provider-ok: codegen runs offline against a single provider
|
|
5391
|
+
const roleCanReadAllSources = (roleId) => sourceEntities.every((e) => e.Permissions.some((p) => UUIDsEqual(p.RoleID, roleId) && p.CanRead));
|
|
5392
|
+
for (const p of configInfo.newEntityDefaults.PermissionDefaults.Permissions) {
|
|
5393
|
+
const roleId = md.Roles.find((r) => r.Name.trim().toLowerCase() === p.RoleName.trim().toLowerCase())?.ID;
|
|
5394
|
+
if (!roleId) {
|
|
5395
|
+
LogError(` >>>> ERROR: Unable to find Role ID for role ${p.RoleName} to add permissions for materialized entity ${entityName}`);
|
|
5396
|
+
continue;
|
|
5397
|
+
}
|
|
5398
|
+
if (!p.CanRead || !roleCanReadAllSources(roleId)) {
|
|
5399
|
+
logStatus(` > Materialized entity "${entityName}": role "${p.RoleName}" NOT granted read (it cannot read every source entity; the snapshot has no row-level scoping).`);
|
|
5400
|
+
continue;
|
|
5401
|
+
}
|
|
5402
|
+
const sSQLInsert = `INSERT INTO ${this.qs(mj_core_schema(), 'EntityPermission')}
|
|
5403
|
+
(${this.qi('EntityID')}, ${this.qi('RoleID')}, ${this.qi('CanRead')}, ${this.qi('CanCreate')}, ${this.qi('CanUpdate')}, ${this.qi('CanDelete')}, ${this.qi('__mj_CreatedAt')}, ${this.qi('__mj_UpdatedAt')}) VALUES
|
|
5404
|
+
('${entityId}', '${roleId}', ${this.boolLit(true)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.boolLit(false)}, ${this.utcNow()}, ${this.utcNow()})`;
|
|
5405
|
+
await this.LogSQLAndExecute(pool, sSQLInsert, `SQL generated to add read permission for materialized entity ${entityName} for role ${p.RoleName}`);
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
/**
|
|
5409
|
+
* Revokes read access on a minted materialized entity by setting `CanRead=0` on all its EntityPermission
|
|
5410
|
+
* rows (the __mj_UpdatedAt trigger stamps the timestamp). Used by the drift re-check when a query source
|
|
5411
|
+
* gains RLS after minting: the snapshot can no longer be safely served, so it is made unreadable until a
|
|
5412
|
+
* human authors protection. Non-destructive (rows are kept, just flipped) so the grant can be restored.
|
|
5413
|
+
*/
|
|
5414
|
+
async revokeMaterializedEntityReadAccess(pool, entityId, entityLabel, reason) {
|
|
5415
|
+
const sql = `UPDATE ${this.qs(mj_core_schema(), 'EntityPermission')} SET ${this.qi('CanRead')}=${this.boolLit(false)} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`;
|
|
5416
|
+
await this.LogSQLAndExecute(pool, sql, `Revoke read access on materialized entity "${entityLabel}" (${reason})`);
|
|
5417
|
+
}
|
|
5418
|
+
/**
|
|
5419
|
+
* ROLE-RLS LAYER ONLY — true if any of the entity's role permissions carries a non-empty `ReadRLSFilterID`.
|
|
5420
|
+
*
|
|
5421
|
+
* This is deliberately NOT a gate on its own, and no materialization gate may call it directly. MJ enforces
|
|
5422
|
+
* row restrictions in TWO layers (see `EntityInfo.GetEffectiveRowFilterWhereClause`, the sanctioned composer):
|
|
5423
|
+
* role RLS *and* API-key row filters, AND-composed. An entity bound ONLY by an API-key row filter carries no
|
|
5424
|
+
* `ReadRLSFilterID` at all, so a gate that stopped at this layer would judge it unprotected and fail OPEN —
|
|
5425
|
+
* minting a materialized entity with a NEW EntityID, which the API-key binding (which binds by EntityID) no
|
|
5426
|
+
* longer matches, handing a filtered principal a full unscoped snapshot of rows it cannot read live.
|
|
5427
|
+
*
|
|
5428
|
+
* `EntityInfo` exposes no user-free accessor for either layer (`GetEffectiveRowFilterWhereClause` needs a
|
|
5429
|
+
* session `UserInfo`, and CodeGen has no session), so the composition is done explicitly here — by
|
|
5430
|
+
* {@link entityHasRowLevelRestriction}, which is what every gate calls.
|
|
5431
|
+
*/
|
|
5432
|
+
entityHasRowLevelSecurity(entity) {
|
|
5433
|
+
return ManageMetadataBase.EntityHasRoleReadRLS(entity);
|
|
5434
|
+
}
|
|
5435
|
+
/** Pure form of the role-RLS layer (see {@link entityHasRowLevelSecurity}). IO-free so it can be reused
|
|
5436
|
+
* verbatim by the runtime refresher's equivalent gate. */
|
|
5437
|
+
static EntityHasRoleReadRLS(entity) {
|
|
5438
|
+
return entity.Permissions.some((p) => !!p.ReadRLSFilterID && p.ReadRLSFilterID.trim().length > 0);
|
|
5439
|
+
}
|
|
5440
|
+
/**
|
|
5441
|
+
* THE row-restriction gate for materialization. True when the entity is protected by ANY row-level
|
|
5442
|
+
* restriction — role RLS **or** an API-key / API-application row filter — because a materialized snapshot
|
|
5443
|
+
* reproduces neither. Fails closed when the API-key layer could not be enumerated (see
|
|
5444
|
+
* {@link apiKeyRowFilterTargets}).
|
|
5445
|
+
*/
|
|
5446
|
+
entityHasRowLevelRestriction(entity) {
|
|
5447
|
+
return ManageMetadataBase.EntityHasRowLevelRestriction(entity, this.apiKeyRowFilterTargets);
|
|
5448
|
+
}
|
|
5449
|
+
/**
|
|
5450
|
+
* Pure, IO-free core of {@link entityHasRowLevelRestriction} — the predicate any other layer (e.g. the
|
|
5451
|
+
* runtime `MaterializationRefresher`) should mirror, supplying the API-key target set however it can obtain
|
|
5452
|
+
* it. `'unknown'` for the target set ⇒ true (fail closed).
|
|
5453
|
+
*/
|
|
5454
|
+
static EntityHasRowLevelRestriction(entity, apiKeyRowFilterTargets) {
|
|
5455
|
+
if (ManageMetadataBase.EntityHasRoleReadRLS(entity))
|
|
5456
|
+
return true;
|
|
5457
|
+
if (apiKeyRowFilterTargets === 'unknown')
|
|
5458
|
+
return true; // cannot prove the key layer is empty → fail closed
|
|
5459
|
+
return apiKeyRowFilterTargets.has((entity.Name ?? '').trim().toLowerCase());
|
|
5460
|
+
}
|
|
5461
|
+
/**
|
|
5462
|
+
* Loads {@link apiKeyRowFilterTargets} once per CodeGen run. Called at the top of every materialization entry
|
|
5463
|
+
* point, BEFORE any gate runs, so the gates never evaluate against an unloaded cache in production.
|
|
5464
|
+
*
|
|
5465
|
+
* Resolution rules (all biased fail-closed):
|
|
5466
|
+
* - The `RowFilterID` column absent on a scope table ⇒ that binding layer cannot exist on this database
|
|
5467
|
+
* (pre-v6 schema / the PostgreSQL parallel world) ⇒ contributes nothing. This is CORRECT, not fail-open.
|
|
5468
|
+
* - A filtered rule's `ResourcePattern` must name ONE exact entity (enforced at rule save). Mappability is
|
|
5469
|
+
* decided by {@link ResolveSingleEntityResourceTarget}, shared verbatim with the runtime refresher's
|
|
5470
|
+
* identical gate; a rule it cannot resolve collapses the whole set to `'unknown'` — every entity is then
|
|
5471
|
+
* treated as restricted.
|
|
5472
|
+
* - Any error enumerating the rules ⇒ `'unknown'` (never a silently-empty set).
|
|
5473
|
+
* Permission type is deliberately NOT narrowed to `Read`: mapping a scope rule to a permission type requires
|
|
5474
|
+
* the APIScope path taxonomy that lives outside CodeGen, and over-restriction here is harmless.
|
|
5475
|
+
*/
|
|
5476
|
+
async loadAPIKeyRowFilterTargets(pool) {
|
|
5477
|
+
if (this.apiKeyRowFilterTargets !== 'unknown')
|
|
5478
|
+
return; // already loaded this run
|
|
5479
|
+
try {
|
|
5480
|
+
const targets = new Set();
|
|
5481
|
+
for (const table of ['APIKeyScope', 'APIApplicationScope']) {
|
|
5482
|
+
if (!(await this.columnExistsInCoreSchema(pool, table, 'RowFilterID')))
|
|
5483
|
+
continue;
|
|
5484
|
+
const res = await this.runQuery(pool, `SELECT DISTINCT ${this.qi('ResourcePattern')} AS ResourcePattern FROM ${this.qs(mj_core_schema(), table)} WHERE ${this.qi('RowFilterID')} IS NOT NULL`);
|
|
5485
|
+
for (const row of res.recordset ?? []) {
|
|
5486
|
+
const r = row;
|
|
5487
|
+
const pattern = (r.ResourcePattern ?? r.resourcepattern) ?? '';
|
|
5488
|
+
// The mappability rule is SHARED with the runtime refresher's identical gate
|
|
5489
|
+
// (ResolveSingleEntityResourceTarget in @memberjunction/global) rather than copied. The two
|
|
5490
|
+
// gates must agree exactly: a copy that drifts open here silently re-opens the leak the
|
|
5491
|
+
// runtime gate closes, and vice versa — with nothing in the build to notice.
|
|
5492
|
+
const target = ResolveSingleEntityResourceTarget(pattern);
|
|
5493
|
+
if (target === null) {
|
|
5494
|
+
logError(` > API-key row-filter enumeration: a filtered scope rule in ${table} has an unmappable ResourcePattern ` +
|
|
5495
|
+
`("${pattern.trim()}") — it cannot be resolved to a single entity, so EVERY entity is treated as row-restricted ` +
|
|
5496
|
+
`for materialization this run (fail closed). Fix the rule to name one exact entity.`);
|
|
5497
|
+
this.apiKeyRowFilterTargets = 'unknown';
|
|
5498
|
+
return;
|
|
5499
|
+
}
|
|
5500
|
+
targets.add(target);
|
|
5501
|
+
}
|
|
5502
|
+
}
|
|
5503
|
+
this.apiKeyRowFilterTargets = targets;
|
|
5504
|
+
}
|
|
5505
|
+
catch (err) {
|
|
5506
|
+
this.apiKeyRowFilterTargets = 'unknown';
|
|
5507
|
+
logError(` > API-key row-filter enumeration FAILED — every entity will be treated as row-restricted for materialization ` +
|
|
5508
|
+
`this run (fail closed): ${err instanceof Error ? err.message : String(err)}`);
|
|
5509
|
+
}
|
|
5510
|
+
}
|
|
5511
|
+
async columnExistsInCoreSchema(pool, table, column) {
|
|
5512
|
+
const key = `${table}.${column}`.toLowerCase();
|
|
5513
|
+
const cached = this._coreSchemaColumnExists.get(key);
|
|
5514
|
+
if (cached !== undefined)
|
|
5515
|
+
return cached;
|
|
5516
|
+
const sql = `SELECT COUNT(*) AS ColExists FROM INFORMATION_SCHEMA.COLUMNS ` +
|
|
5517
|
+
`WHERE TABLE_SCHEMA = '${mj_core_schema()}' AND TABLE_NAME = '${table}' AND COLUMN_NAME = '${column}'`;
|
|
5518
|
+
const result = await this.runQuery(pool, sql);
|
|
5519
|
+
const row = (result.recordset?.[0] ?? {});
|
|
5520
|
+
const cnt = row.ColExists ?? row.colexists ?? 0;
|
|
5521
|
+
const exists = Number(cnt) > 0;
|
|
5522
|
+
this._coreSchemaColumnExists.set(key, exists);
|
|
5523
|
+
return exists;
|
|
5524
|
+
}
|
|
5525
|
+
/**
|
|
5526
|
+
* Leak-2 (C2 ongoing): re-narrows a minted materialized QUERY entity's read grants to the CURRENT source-read
|
|
5527
|
+
* intersection. The mint-time grant ({@link addMaterializedQueryEntityPermissions}) is computed once; if a role
|
|
5528
|
+
* later LOSES plain `CanRead` on a source, its grant on the snapshot must be revoked too — otherwise it keeps
|
|
5529
|
+
* reading snapshot rows it can no longer read live. Called from the drift pass on every codegen run. Only ever
|
|
5530
|
+
* REVOKES (the safe direction); re-granting a role that regained access is a human decision after review.
|
|
5531
|
+
* Returns the number of grants revoked.
|
|
5532
|
+
*/
|
|
5533
|
+
async reconcileMaterializedQueryEntityReadGrants(pool, entityId, entityLabel, sourceEntities) {
|
|
5534
|
+
if (sourceEntities.length === 0)
|
|
5535
|
+
return 0; // no sources to intersect against — the RLS/empty gate handles this
|
|
5536
|
+
const roleCanReadAllSources = (roleId) => sourceEntities.every((e) => e.Permissions.some((p) => UUIDsEqual(p.RoleID, roleId) && p.CanRead));
|
|
5537
|
+
const grants = await this.runQuery(pool, `SELECT ${this.qi('RoleID')} FROM ${this.qs(mj_core_schema(), 'EntityPermission')} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`);
|
|
5538
|
+
let revoked = 0;
|
|
5539
|
+
for (const g of grants.recordset) {
|
|
5540
|
+
const roleId = g.RoleID;
|
|
5541
|
+
if (!roleId)
|
|
5542
|
+
continue;
|
|
5543
|
+
if (!roleCanReadAllSources(roleId)) {
|
|
5544
|
+
await this.LogSQLAndExecute(pool, `UPDATE ${this.qs(mj_core_schema(), 'EntityPermission')} SET ${this.qi('CanRead')}=${this.boolLit(false)} WHERE ${this.qi('EntityID')}='${entityId}' AND ${this.qi('RoleID')}='${roleId}' AND ${this.qi('CanRead')}=${this.boolLit(true)}`, `Narrow read grant on materialized entity "${entityLabel}": role ${roleId} can no longer read every source (C2 intersection re-narrowed)`);
|
|
5545
|
+
revoked++;
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
return revoked;
|
|
5549
|
+
}
|
|
4419
5550
|
createNewEntityInsertSQL(newEntityUUID, newEntityName, newEntity, newEntitySuffix, newEntityDisplayName) {
|
|
4420
5551
|
const newEntityDefaults = configInfo.newEntityDefaults;
|
|
4421
5552
|
const newEntityDescriptionEscaped = newEntity.EntityDescription ? `'${newEntity.EntityDescription.replace(/'/g, "''")}'` : null;
|