arc-1 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/bin/start-cf.sh +32 -0
- package/dist/adt/bounded-response.d.ts +9 -0
- package/dist/adt/bounded-response.d.ts.map +1 -0
- package/dist/adt/bounded-response.js +216 -0
- package/dist/adt/bounded-response.js.map +1 -0
- package/dist/adt/client.d.ts +35 -18
- package/dist/adt/client.d.ts.map +1 -1
- package/dist/adt/client.js +100 -104
- package/dist/adt/client.js.map +1 -1
- package/dist/adt/config.d.ts +6 -0
- package/dist/adt/config.d.ts.map +1 -1
- package/dist/adt/config.js +3 -0
- package/dist/adt/config.js.map +1 -1
- package/dist/adt/data-result-context.d.ts +46 -0
- package/dist/adt/data-result-context.d.ts.map +1 -0
- package/dist/adt/data-result-context.js +118 -0
- package/dist/adt/data-result-context.js.map +1 -0
- package/dist/adt/data-source-name.d.ts +60 -0
- package/dist/adt/data-source-name.d.ts.map +1 -0
- package/dist/adt/data-source-name.js +156 -0
- package/dist/adt/data-source-name.js.map +1 -0
- package/dist/adt/data-source-policy.d.ts +148 -0
- package/dist/adt/data-source-policy.d.ts.map +1 -0
- package/dist/adt/data-source-policy.js +752 -0
- package/dist/adt/data-source-policy.js.map +1 -0
- package/dist/adt/errors.d.ts +10 -0
- package/dist/adt/errors.d.ts.map +1 -1
- package/dist/adt/errors.js +17 -0
- package/dist/adt/errors.js.map +1 -1
- package/dist/adt/http-deadline.d.ts +5 -0
- package/dist/adt/http-deadline.d.ts.map +1 -1
- package/dist/adt/http-deadline.js +7 -0
- package/dist/adt/http-deadline.js.map +1 -1
- package/dist/adt/http.d.ts.map +1 -1
- package/dist/adt/http.js +43 -46
- package/dist/adt/http.js.map +1 -1
- package/dist/adt/internal-data-operations.d.ts +51 -0
- package/dist/adt/internal-data-operations.d.ts.map +1 -0
- package/dist/adt/internal-data-operations.js +95 -0
- package/dist/adt/internal-data-operations.js.map +1 -0
- package/dist/adt/safety.d.ts +2 -0
- package/dist/adt/safety.d.ts.map +1 -1
- package/dist/adt/safety.js +6 -0
- package/dist/adt/safety.js.map +1 -1
- package/dist/adt/sql-source-analyzer.d.ts +12 -0
- package/dist/adt/sql-source-analyzer.d.ts.map +1 -0
- package/dist/adt/sql-source-analyzer.js +158 -0
- package/dist/adt/sql-source-analyzer.js.map +1 -0
- package/dist/adt/table-query.d.ts +41 -0
- package/dist/adt/table-query.d.ts.map +1 -0
- package/dist/adt/table-query.js +99 -0
- package/dist/adt/table-query.js.map +1 -0
- package/dist/adt/xml-parser.d.ts +10 -15
- package/dist/adt/xml-parser.d.ts.map +1 -1
- package/dist/adt/xml-parser.js +24 -11
- package/dist/adt/xml-parser.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/handlers/diagnose.d.ts.map +1 -1
- package/dist/handlers/diagnose.js +14 -1
- package/dist/handlers/diagnose.js.map +1 -1
- package/dist/handlers/dispatch.d.ts +3 -1
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +74 -6
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/navigate.d.ts.map +1 -1
- package/dist/handlers/navigate.js +23 -3
- package/dist/handlers/navigate.js.map +1 -1
- package/dist/handlers/object-types.d.ts +4 -4
- package/dist/handlers/object-types.js +4 -4
- package/dist/handlers/query.d.ts +1 -1
- package/dist/handlers/query.d.ts.map +1 -1
- package/dist/handlers/query.js +13 -20
- package/dist/handlers/query.js.map +1 -1
- package/dist/handlers/read.d.ts.map +1 -1
- package/dist/handlers/read.js +68 -12
- package/dist/handlers/read.js.map +1 -1
- package/dist/handlers/search.d.ts.map +1 -1
- package/dist/handlers/search.js +27 -5
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/tools.d.ts.map +1 -1
- package/dist/handlers/tools.js +13 -13
- package/dist/handlers/tools.js.map +1 -1
- package/dist/handlers/where-used.d.ts +2 -0
- package/dist/handlers/where-used.d.ts.map +1 -1
- package/dist/handlers/where-used.js +23 -5
- package/dist/handlers/where-used.js.map +1 -1
- package/dist/public/index.d.ts +1 -1
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/index.js.map +1 -1
- package/dist/server/audit.d.ts +42 -1
- package/dist/server/audit.d.ts.map +1 -1
- package/dist/server/audit.js.map +1 -1
- package/dist/server/config.d.ts +4 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +67 -0
- package/dist/server/config.js.map +1 -1
- package/dist/server/context.d.ts +5 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/context.js.map +1 -1
- package/dist/server/destination-registry.d.ts +10 -2
- package/dist/server/destination-registry.d.ts.map +1 -1
- package/dist/server/destination-registry.js +12 -3
- package/dist/server/destination-registry.js.map +1 -1
- package/dist/server/effective-policy-log.d.ts +2 -1
- package/dist/server/effective-policy-log.d.ts.map +1 -1
- package/dist/server/effective-policy-log.js +18 -2
- package/dist/server/effective-policy-log.js.map +1 -1
- package/dist/server/multi-target-runtime.d.ts.map +1 -1
- package/dist/server/multi-target-runtime.js +7 -2
- package/dist/server/multi-target-runtime.js.map +1 -1
- package/dist/server/runtime-memory.d.ts +13 -0
- package/dist/server/runtime-memory.d.ts.map +1 -0
- package/dist/server/runtime-memory.js +42 -0
- package/dist/server/runtime-memory.js.map +1 -0
- package/dist/server/server-instructions.d.ts +2 -0
- package/dist/server/server-instructions.d.ts.map +1 -0
- package/dist/server/server-instructions.js +27 -0
- package/dist/server/server-instructions.js.map +1 -0
- package/dist/server/server.d.ts +3 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +25 -35
- package/dist/server/server.js.map +1 -1
- package/dist/server/sinks/btp-auditlog.d.ts.map +1 -1
- package/dist/server/sinks/btp-auditlog.js +10 -0
- package/dist/server/sinks/btp-auditlog.js.map +1 -1
- package/dist/server/types.d.ts +8 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +4 -0
- package/dist/server/types.js.map +1 -1
- package/dist/server/ui-state.d.ts.map +1 -1
- package/dist/server/ui-state.js +1 -0
- package/dist/server/ui-state.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/** Experimental exact-name data-source blocklist and live CDS-lineage evaluation. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
4
|
+
import { logger } from '../server/logger.js';
|
|
5
|
+
import { canonicalDataSourceName, DataSourceNameError, dataSourcePolicyFingerprint } from './data-source-name.js';
|
|
6
|
+
import { AdtApiError, AdtNetworkError, AdtSafetyError } from './errors.js';
|
|
7
|
+
import { analyzeSqlDataSources } from './sql-source-analyzer.js';
|
|
8
|
+
const MAX_GRAPH_XML_CHARS = 5_000_000;
|
|
9
|
+
const MAX_GRAPH_DEPTH = 64;
|
|
10
|
+
const MAX_GRAPH_NODES = 1_000;
|
|
11
|
+
const MAX_DIRECT_SOURCES = 64;
|
|
12
|
+
const MAX_REPLACEMENT_SOURCES = 256;
|
|
13
|
+
const MAX_RESOLUTION_ROOTS = 64;
|
|
14
|
+
const graphParser = new XMLParser({
|
|
15
|
+
ignoreAttributes: false,
|
|
16
|
+
attributeNamePrefix: '@_',
|
|
17
|
+
removeNSPrefix: true,
|
|
18
|
+
isArray: (name) => name === 'elementInfo' || name === 'entry',
|
|
19
|
+
parseAttributeValue: false,
|
|
20
|
+
parseTagValue: false,
|
|
21
|
+
processEntities: false,
|
|
22
|
+
});
|
|
23
|
+
class DataSourceLineageError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'DataSourceLineageError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Opaque, bounded, non-secret correlation id shared by the client error and the audit record. */
|
|
30
|
+
export function newDecisionId() {
|
|
31
|
+
return `dsp_${randomBytes(8).toString('hex')}`;
|
|
32
|
+
}
|
|
33
|
+
export class DataSourcePolicyError extends AdtSafetyError {
|
|
34
|
+
code;
|
|
35
|
+
directSource;
|
|
36
|
+
sourcePath;
|
|
37
|
+
decisionId;
|
|
38
|
+
matchedSource;
|
|
39
|
+
reason;
|
|
40
|
+
/** Always false: every policy denial happens before the SAP data request is submitted. */
|
|
41
|
+
executed = false;
|
|
42
|
+
constructor(code, directSource, sourcePath, reason, options = {}) {
|
|
43
|
+
const path = sourcePath.length > 0 ? sourcePath.join(' -> ') : directSource;
|
|
44
|
+
const decisionId = options.decisionId ?? newDecisionId();
|
|
45
|
+
super(`${code}: request denied before data execution (executed=false, decisionId=${decisionId}). ` +
|
|
46
|
+
`Source path: ${path}. Reason: ${reason}. ` +
|
|
47
|
+
'Operator action: use a permitted static source, or change SAP_BLOCKED_DATA_SOURCES only after security review.');
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.directSource = directSource;
|
|
50
|
+
this.sourcePath = sourcePath;
|
|
51
|
+
this.name = 'DataSourcePolicyError';
|
|
52
|
+
this.decisionId = decisionId;
|
|
53
|
+
this.reason = reason;
|
|
54
|
+
if (options.matchedSource)
|
|
55
|
+
this.matchedSource = options.matchedSource;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Client-facing text.
|
|
59
|
+
*
|
|
60
|
+
* `ARC1_MINIMAL_ERRORS` is a CLIENT DISCLOSURE control only: it removes the direct root, the
|
|
61
|
+
* matched rule, the dependency path and the configuration variable name, keeping just enough for
|
|
62
|
+
* the model to act (stable code, executed=false, decision id, and a safe alternative). It does not
|
|
63
|
+
* change the decision and does not reduce what the audit event records.
|
|
64
|
+
*
|
|
65
|
+
* The three codes stay distinguishable even in minimal mode, which does permit coarse membership
|
|
66
|
+
* probing. That is a deliberate, documented trade: a model that cannot tell "blocked by policy"
|
|
67
|
+
* from "SQL not supported" cannot correct itself.
|
|
68
|
+
*/
|
|
69
|
+
clientMessage(minimalErrors) {
|
|
70
|
+
if (!minimalErrors)
|
|
71
|
+
return this.message;
|
|
72
|
+
return (`${this.code}: the request was denied by the administrator's data-source policy before any SAP ` +
|
|
73
|
+
`data request was executed (executed=false, decisionId=${this.decisionId}). ` +
|
|
74
|
+
`${this.safeAlternative()} Details are recorded in the server audit log; ask an operator to ` +
|
|
75
|
+
'correlate the decision id.');
|
|
76
|
+
}
|
|
77
|
+
/** Guidance that does not reveal policy-sensitive names. */
|
|
78
|
+
safeAlternative() {
|
|
79
|
+
switch (this.code) {
|
|
80
|
+
case 'DATA_SQL_UNSUPPORTED':
|
|
81
|
+
return 'Rewrite the request as one complete static SELECT/WITH without comments, host expressions or dynamic sources, or use the structured SAPRead(type="TABLE_QUERY") parameters.';
|
|
82
|
+
case 'DATA_LINEAGE_UNRESOLVED':
|
|
83
|
+
return 'Query a source whose lineage ARC-1 can resolve, or use the structured SAPRead(type="TABLE_QUERY") parameters.';
|
|
84
|
+
default:
|
|
85
|
+
return 'Use a different data source.';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* SQL node kinds SAP emits in the dependency branch. Verified live on SAP_BASIS 750, 758 and 816.
|
|
91
|
+
* Anything outside this set inside a SQL branch fails closed.
|
|
92
|
+
*/
|
|
93
|
+
export const SQL_NODE_KINDS = ['CDS_VIEW', 'TABLE', 'CDS_TABLE_FUNCTION'];
|
|
94
|
+
/**
|
|
95
|
+
* The EXACT auxiliary chain SAP emits for access-control metadata, verified live on 758/816 for the
|
|
96
|
+
* released standard view I_BUSINESSPARTNER:
|
|
97
|
+
*
|
|
98
|
+
* RELATED_OBJECTS_TREE -> RELATED_OBJECTS_ENTRY -> DCLS_OBJECT_LIST -> DCLS/DL leaf
|
|
99
|
+
*
|
|
100
|
+
* These carry no application data, so they are validated and then dropped from lineage. Matching is
|
|
101
|
+
* on the exact TYPE value and exact nesting — never on a name merely containing "RELATED" or "DCLS",
|
|
102
|
+
* which would let an attacker-shaped subtree hide a real data source.
|
|
103
|
+
*/
|
|
104
|
+
const AUXILIARY_CHILD_KIND = {
|
|
105
|
+
RELATED_OBJECTS_TREE: 'RELATED_OBJECTS_ENTRY',
|
|
106
|
+
RELATED_OBJECTS_ENTRY: 'DCLS_OBJECT_LIST',
|
|
107
|
+
DCLS_OBJECT_LIST: 'DCLS_OBJECT',
|
|
108
|
+
};
|
|
109
|
+
/** adtcore:type of the terminal access-control object; it legitimately carries no properties. */
|
|
110
|
+
const DCL_OBJECT_ADTCORE_TYPE = 'DCLS/DL';
|
|
111
|
+
/** Request-scoped adapter from ADT metadata reads to the pure lineage evaluator. */
|
|
112
|
+
export class DataSourceBlocklistGuard {
|
|
113
|
+
blockedSources;
|
|
114
|
+
backend;
|
|
115
|
+
/** Per-decision instrumentation. The guard is constructed per logical request and never reused. */
|
|
116
|
+
metadataRequests = 0;
|
|
117
|
+
graphNodes = 0;
|
|
118
|
+
constructor(blockedSources, backend) {
|
|
119
|
+
this.blockedSources = blockedSources;
|
|
120
|
+
this.backend = backend;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Run one policy decision and record exactly one audit event for it, allow or deny.
|
|
124
|
+
*
|
|
125
|
+
* The audit record is the protected copy: it always carries the complete normalized decision, so
|
|
126
|
+
* an operator can reconstruct a denial even when the client was told almost nothing.
|
|
127
|
+
*/
|
|
128
|
+
async decide(directRootsHint, run) {
|
|
129
|
+
const decisionId = newDecisionId();
|
|
130
|
+
const started = Date.now();
|
|
131
|
+
const fingerprint = dataSourcePolicyFingerprint(this.blockedSources);
|
|
132
|
+
let directRoots = directRootsHint;
|
|
133
|
+
try {
|
|
134
|
+
directRoots = await run();
|
|
135
|
+
logger.emitAudit({
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
level: 'info',
|
|
138
|
+
event: 'data_source_policy_decision',
|
|
139
|
+
decision: 'allow',
|
|
140
|
+
decisionId,
|
|
141
|
+
executed: true,
|
|
142
|
+
directRoots,
|
|
143
|
+
policyFingerprint: fingerprint,
|
|
144
|
+
metadataRequests: this.metadataRequests,
|
|
145
|
+
graphNodes: this.graphNodes,
|
|
146
|
+
durationMs: Date.now() - started,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!(error instanceof DataSourcePolicyError)) {
|
|
151
|
+
// Unexpected failures still deny (fail closed), but the client only ever sees a generic,
|
|
152
|
+
// redacted reason. Log the real error here so a genuine defect cannot hide behind
|
|
153
|
+
// "lineage could not be proven" with no diagnostic anywhere.
|
|
154
|
+
logger.error(`data-source policy decision ${decisionId} failed unexpectedly: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`);
|
|
155
|
+
}
|
|
156
|
+
const policyError = error instanceof DataSourcePolicyError
|
|
157
|
+
? error
|
|
158
|
+
: new DataSourcePolicyError('DATA_LINEAGE_UNRESOLVED', directRoots[0] ?? 'UNKNOWN', [], safeLineageFailureReason(error), { decisionId });
|
|
159
|
+
logger.emitAudit({
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
level: 'warn',
|
|
162
|
+
event: 'data_source_policy_decision',
|
|
163
|
+
decision: 'deny',
|
|
164
|
+
decisionId: policyError.decisionId,
|
|
165
|
+
code: policyError.code,
|
|
166
|
+
executed: false,
|
|
167
|
+
directRoots,
|
|
168
|
+
...(policyError.matchedSource ? { matchedSource: policyError.matchedSource } : {}),
|
|
169
|
+
...(policyError.sourcePath.length > 0 ? { sourcePath: policyError.sourcePath } : {}),
|
|
170
|
+
reason: policyError.reason,
|
|
171
|
+
policyFingerprint: fingerprint,
|
|
172
|
+
metadataRequests: this.metadataRequests,
|
|
173
|
+
graphNodes: this.graphNodes,
|
|
174
|
+
durationMs: Date.now() - started,
|
|
175
|
+
});
|
|
176
|
+
throw policyError;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async enforceTableContents(tableName, sqlFilter) {
|
|
180
|
+
if (this.blockedSources.length === 0)
|
|
181
|
+
return;
|
|
182
|
+
if (!sqlFilter?.trim())
|
|
183
|
+
return this.enforceSources([tableName]);
|
|
184
|
+
// A filtered DDIC preview is refused whatever the table is, but a directly blocked table still
|
|
185
|
+
// reports as blocked so the operator sees the real reason.
|
|
186
|
+
await this.decide([tableName], async () => {
|
|
187
|
+
const source = canonicalDataSourceName(tableName, 'TABLE_CONTENTS table name');
|
|
188
|
+
// Canonicalize the configured list here too. resolveConfig() already stores canonical entries,
|
|
189
|
+
// but enforceBlockedDataSources() re-normalizes defensively and this branch must not be the one
|
|
190
|
+
// place that trusts its input shape.
|
|
191
|
+
const blocked = this.blockedSources.map((name) => canonicalDataSourceName(name));
|
|
192
|
+
if (blocked.includes(source)) {
|
|
193
|
+
throw new DataSourcePolicyError('DATA_SOURCE_BLOCKED', source, [source], `exact source ${source} matches the configured blocklist`, {
|
|
194
|
+
matchedSource: source,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
throw new DataSourcePolicyError('DATA_SQL_UNSUPPORTED', source, [source], 'the TABLE_CONTENTS sqlFilter condition language is outside the strict analyzed subset; use the structured TABLE_QUERY where/columns parameters instead');
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Authorize every statement of one logical request together.
|
|
202
|
+
*
|
|
203
|
+
* The union of canonical direct sources is deduplicated before any SAP call, so N chunks that all
|
|
204
|
+
* read the same table cost exactly one lineage resolution, and a source appearing in only one chunk
|
|
205
|
+
* still denies the whole batch.
|
|
206
|
+
*/
|
|
207
|
+
async enforceSqlBatch(statements) {
|
|
208
|
+
if (this.blockedSources.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
await this.decide([], async () => {
|
|
211
|
+
const union = [];
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
for (const statement of statements) {
|
|
214
|
+
for (const source of this.analyzeOrThrow(statement)) {
|
|
215
|
+
if (!seen.has(source)) {
|
|
216
|
+
seen.add(source);
|
|
217
|
+
union.push(source);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await this.evaluate(union);
|
|
222
|
+
return union;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
analyzeOrThrow(sql) {
|
|
226
|
+
try {
|
|
227
|
+
return analyzeSqlDataSources(sql);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
throw new DataSourcePolicyError('DATA_SQL_UNSUPPORTED', 'SQL', [], error instanceof Error ? error.message : String(error));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async enforceSources(directSources) {
|
|
234
|
+
if (this.blockedSources.length === 0)
|
|
235
|
+
return;
|
|
236
|
+
await this.decide(directSources, async () => {
|
|
237
|
+
await this.evaluate(directSources);
|
|
238
|
+
return directSources;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** The evaluation itself, without audit framing, so exactly one event is emitted per request. */
|
|
242
|
+
async evaluate(directSources) {
|
|
243
|
+
await enforceBlockedDataSources(directSources, this.blockedSources, {
|
|
244
|
+
resolveDirectSource: (name) => this.resolveDirectSource(name),
|
|
245
|
+
readTableSource: (name) => {
|
|
246
|
+
this.metadataRequests += 1;
|
|
247
|
+
return this.backend.readTableSource(name);
|
|
248
|
+
},
|
|
249
|
+
readCdsDependencyGraph: (ddlSource) => this.readCdsDependencyGraph(ddlSource),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async resolveDirectSource(name) {
|
|
253
|
+
const requested = canonicalDataSourceName(name);
|
|
254
|
+
const canonicalSearchName = (displayName) => {
|
|
255
|
+
const match = displayName
|
|
256
|
+
.trim()
|
|
257
|
+
.toUpperCase()
|
|
258
|
+
.match(/^([A-Z0-9_/$]+)(?: \([^)]*\))?$/);
|
|
259
|
+
return match?.[1];
|
|
260
|
+
};
|
|
261
|
+
// NW 7.50 decorates exact names (for example "SCARR (Database Table)"
|
|
262
|
+
// and "X (Entity)"). Fuzzy search hits cannot prove source identity.
|
|
263
|
+
this.metadataRequests += 1;
|
|
264
|
+
const matches = (await this.backend.searchObject(requested, 100)).filter((match) => canonicalSearchName(match.objectName) === requested);
|
|
265
|
+
const stobMatches = matches.filter((match) => match.objectType.toUpperCase().startsWith('STOB/'));
|
|
266
|
+
const ddlsMatches = matches.filter((match) => match.objectType.toUpperCase() === 'DDLS/DF');
|
|
267
|
+
const tableMatches = matches.filter((match) => match.objectType.toUpperCase() === 'TABL/DT');
|
|
268
|
+
const structureMatches = matches.filter((match) => match.objectType.toUpperCase() === 'TABL/DS');
|
|
269
|
+
const viewMatches = matches.filter((match) => match.objectType.toUpperCase() === 'VIEW/DV');
|
|
270
|
+
const competingKinds = Number(stobMatches.length > 0) + Number(tableMatches.length > 0) + Number(viewMatches.length > 0);
|
|
271
|
+
if (competingKinds > 1)
|
|
272
|
+
return { kind: 'unknown', name };
|
|
273
|
+
if (stobMatches.length > 0) {
|
|
274
|
+
const ddlSources = new Set();
|
|
275
|
+
for (const match of [...stobMatches, ...ddlsMatches]) {
|
|
276
|
+
const sourceMatch = match.uri.match(/\/ddic\/ddl\/sources\/([^/?#]+)(?:\/source\/main)?(?:#|$)/i);
|
|
277
|
+
if (!sourceMatch?.[1])
|
|
278
|
+
continue;
|
|
279
|
+
try {
|
|
280
|
+
ddlSources.add(canonicalDataSourceName(decodeURIComponent(sourceMatch[1])));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return { kind: 'unknown', name };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (ddlSources.size !== 1)
|
|
287
|
+
return { kind: 'unknown', name };
|
|
288
|
+
return { kind: 'cds', name, ddlSource: [...ddlSources][0] };
|
|
289
|
+
}
|
|
290
|
+
if (tableMatches.length === 1)
|
|
291
|
+
return { kind: 'table', name };
|
|
292
|
+
if (tableMatches.length > 1)
|
|
293
|
+
return { kind: 'unknown', name };
|
|
294
|
+
if (viewMatches.length > 0)
|
|
295
|
+
return { kind: 'classic-view', name };
|
|
296
|
+
if (structureMatches.length > 0)
|
|
297
|
+
return { kind: 'structure', name };
|
|
298
|
+
return { kind: 'unknown', name };
|
|
299
|
+
}
|
|
300
|
+
async readCdsDependencyGraph(ddlSource) {
|
|
301
|
+
const collection = CDS_DEPENDENCY_GRAPH_PATH;
|
|
302
|
+
const requestGraph = async (accept) => {
|
|
303
|
+
const params = new URLSearchParams({ ddlsourceName: ddlSource });
|
|
304
|
+
// Metrics add roughly 40% payload without changing topology, and authorization only needs
|
|
305
|
+
// topology. Live-verified: the v3 media type returns 406 on SAP_BASIS 750, which is what
|
|
306
|
+
// drives the element-info fallback below.
|
|
307
|
+
if (/SQLDependencyModel/i.test(accept))
|
|
308
|
+
params.set('addMetrics', 'false');
|
|
309
|
+
this.metadataRequests += 1;
|
|
310
|
+
const graph = parseCdsDependencyGraph(await this.backend.readDependencyGraph(`${collection}?${params.toString()}`, accept));
|
|
311
|
+
const count = (node) => 1 + node.children.reduce((n, c) => n + count(c), 0);
|
|
312
|
+
this.graphNodes += count(graph);
|
|
313
|
+
return graph;
|
|
314
|
+
};
|
|
315
|
+
const discoveredAccept = this.backend.dependencyGraphAccept();
|
|
316
|
+
if (discoveredAccept)
|
|
317
|
+
return requestGraph(discoveredAccept);
|
|
318
|
+
try {
|
|
319
|
+
return await requestGraph('application/vnd.sap.adt.ddl.SQLDependencyModel.v3+xml');
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (!(error instanceof AdtApiError) || ![406, 415].includes(error.statusCode))
|
|
323
|
+
throw error;
|
|
324
|
+
return requestGraph('application/vnd.sap.adt.elementinfo+xml');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Endpoint the SQL dependency graph is served from. */
|
|
329
|
+
export const CDS_DEPENDENCY_GRAPH_PATH = '/sap/bc/adt/ddic/ddl/dependencies/graphdata';
|
|
330
|
+
/**
|
|
331
|
+
* Wire a request-scoped guard from the ADT primitives it needs.
|
|
332
|
+
*
|
|
333
|
+
* Lives here rather than on the client so the ADT facade stays a facade: this is policy wiring, and
|
|
334
|
+
* a new guard is built per logical request so its instrumentation can never leak between decisions.
|
|
335
|
+
*/
|
|
336
|
+
export function createDataSourceBlocklistGuard(deps) {
|
|
337
|
+
return new DataSourceBlocklistGuard(deps.blockedDataSources, deps);
|
|
338
|
+
}
|
|
339
|
+
function asRecord(value) {
|
|
340
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
341
|
+
}
|
|
342
|
+
function firstRecord(value) {
|
|
343
|
+
if (Array.isArray(value))
|
|
344
|
+
return asRecord(value[0]);
|
|
345
|
+
return asRecord(value);
|
|
346
|
+
}
|
|
347
|
+
function attribute(node, name) {
|
|
348
|
+
const value = node[`@_${name}`];
|
|
349
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
350
|
+
}
|
|
351
|
+
function parseProperties(node) {
|
|
352
|
+
const properties = firstRecord(node.properties);
|
|
353
|
+
const entries = Array.isArray(properties?.entry) ? properties.entry : properties?.entry ? [properties.entry] : [];
|
|
354
|
+
const result = new Map();
|
|
355
|
+
for (const rawEntry of entries) {
|
|
356
|
+
const entry = asRecord(rawEntry);
|
|
357
|
+
if (!entry)
|
|
358
|
+
continue;
|
|
359
|
+
const key = attribute(entry, 'key')?.toUpperCase();
|
|
360
|
+
const text = entry['#text'];
|
|
361
|
+
const value = attribute(entry, 'value') ?? (typeof text === 'string' && text.trim() ? text.trim() : undefined);
|
|
362
|
+
if (key && value !== undefined)
|
|
363
|
+
result.set(key, value);
|
|
364
|
+
}
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Parse SAP dependency-analyzer XML (750 element-info and 758/816 SQLDependencyModel v3) into one
|
|
369
|
+
* bounded, explicitly classified tree.
|
|
370
|
+
*
|
|
371
|
+
* Every node is classified before it can influence a decision. SQL nodes enter lineage; the verified
|
|
372
|
+
* auxiliary access-control chain is validated and dropped; anything else fails closed. Explicit kind
|
|
373
|
+
* classification is the primary boundary here — a later failed table-source read is defence in depth,
|
|
374
|
+
* not the protection.
|
|
375
|
+
*/
|
|
376
|
+
export function parseCdsDependencyGraph(xml) {
|
|
377
|
+
if (xml.length > MAX_GRAPH_XML_CHARS) {
|
|
378
|
+
throw new DataSourceLineageError(`CDS dependency graph exceeds input limit ${MAX_GRAPH_XML_CHARS} characters`);
|
|
379
|
+
}
|
|
380
|
+
let parsed;
|
|
381
|
+
try {
|
|
382
|
+
parsed = graphParser.parse(xml);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
throw new DataSourceLineageError('CDS dependency graph XML is malformed');
|
|
386
|
+
}
|
|
387
|
+
const root = firstRecord(parsed.elementInfo);
|
|
388
|
+
if (!root)
|
|
389
|
+
throw new DataSourceLineageError('CDS dependency graph is missing its root elementInfo node');
|
|
390
|
+
let nodeCount = 0;
|
|
391
|
+
const rawChildren = (raw) => {
|
|
392
|
+
const kids = Array.isArray(raw.elementInfo) ? raw.elementInfo : raw.elementInfo ? [raw.elementInfo] : [];
|
|
393
|
+
return kids.map((child) => {
|
|
394
|
+
const record = asRecord(child);
|
|
395
|
+
if (!record)
|
|
396
|
+
throw new DataSourceLineageError('CDS dependency graph contains a malformed child node');
|
|
397
|
+
return record;
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
const countNode = (depth) => {
|
|
401
|
+
if (depth > MAX_GRAPH_DEPTH)
|
|
402
|
+
throw new DataSourceLineageError(`CDS dependency graph exceeds depth limit ${MAX_GRAPH_DEPTH}`);
|
|
403
|
+
nodeCount += 1;
|
|
404
|
+
if (nodeCount > MAX_GRAPH_NODES)
|
|
405
|
+
throw new DataSourceLineageError(`CDS dependency graph exceeds node limit ${MAX_GRAPH_NODES}`);
|
|
406
|
+
};
|
|
407
|
+
/** Validate one node of the auxiliary access-control chain and everything under it. */
|
|
408
|
+
const visitAuxiliary = (raw, expected, depth) => {
|
|
409
|
+
countNode(depth);
|
|
410
|
+
const properties = parseProperties(raw);
|
|
411
|
+
const declared = properties.get('TYPE')?.trim().toUpperCase();
|
|
412
|
+
if (expected === 'DCLS_OBJECT') {
|
|
413
|
+
// The terminal DCL object carries an adtcore:type and, live, no properties at all.
|
|
414
|
+
if (declared !== undefined) {
|
|
415
|
+
throw new DataSourceLineageError(`access-control object declares unexpected TYPE ${declared}`);
|
|
416
|
+
}
|
|
417
|
+
if (attribute(raw, 'type')?.toUpperCase() !== DCL_OBJECT_ADTCORE_TYPE) {
|
|
418
|
+
throw new DataSourceLineageError('access-control branch terminal is not a DCLS/DL object');
|
|
419
|
+
}
|
|
420
|
+
if (rawChildren(raw).length > 0) {
|
|
421
|
+
throw new DataSourceLineageError('access-control object has unexpected children');
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (declared !== expected) {
|
|
426
|
+
throw new DataSourceLineageError(`access-control branch expected ${expected} but found ${declared ?? 'a node with no TYPE'}`);
|
|
427
|
+
}
|
|
428
|
+
const nextExpected = AUXILIARY_CHILD_KIND[expected];
|
|
429
|
+
for (const child of rawChildren(raw))
|
|
430
|
+
visitAuxiliary(child, nextExpected, depth + 1);
|
|
431
|
+
};
|
|
432
|
+
const visit = (raw, depth) => {
|
|
433
|
+
countNode(depth);
|
|
434
|
+
const properties = parseProperties(raw);
|
|
435
|
+
const declared = properties.get('TYPE')?.trim().toUpperCase();
|
|
436
|
+
if (!declared) {
|
|
437
|
+
throw new DataSourceLineageError('CDS dependency graph node is missing TYPE');
|
|
438
|
+
}
|
|
439
|
+
if (!SQL_NODE_KINDS.includes(declared)) {
|
|
440
|
+
throw new DataSourceLineageError(`CDS dependency graph node declares unsupported kind ${declared}`);
|
|
441
|
+
}
|
|
442
|
+
const kind = declared;
|
|
443
|
+
const rawName = attribute(raw, 'name');
|
|
444
|
+
if (!rawName)
|
|
445
|
+
throw new DataSourceLineageError('CDS dependency graph node is missing its name');
|
|
446
|
+
let name;
|
|
447
|
+
try {
|
|
448
|
+
name = canonicalDataSourceName(rawName, 'CDS dependency graph node');
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
throw new DataSourceLineageError('CDS dependency graph node has an invalid technical name');
|
|
452
|
+
}
|
|
453
|
+
// ENTITY_NAME is mixed case live (I_BusinessPartner) and can be empty on table nodes.
|
|
454
|
+
const aliases = new Set([name]);
|
|
455
|
+
for (const key of ['ENTITY_NAME', 'NODE_NAME']) {
|
|
456
|
+
const value = properties.get(key);
|
|
457
|
+
if (!value)
|
|
458
|
+
continue;
|
|
459
|
+
try {
|
|
460
|
+
aliases.add(canonicalDataSourceName(value, `CDS dependency graph ${key}`));
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
throw new DataSourceLineageError(`CDS dependency graph node ${name} has an invalid ${key}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const accessState = properties.get('AC_STATE')?.trim().toUpperCase();
|
|
467
|
+
const hasDcl = properties.get('HAS_DCL')?.trim().toUpperCase() === 'X';
|
|
468
|
+
let accessControlled = hasDcl || (accessState !== undefined && !['NA', 'NONE', ''].includes(accessState));
|
|
469
|
+
const children = [];
|
|
470
|
+
for (const child of rawChildren(raw)) {
|
|
471
|
+
const childType = parseProperties(child).get('TYPE')?.trim().toUpperCase();
|
|
472
|
+
if (childType !== undefined && childType in AUXILIARY_CHILD_KIND) {
|
|
473
|
+
// A recognized auxiliary branch: validate its exact shape, record that access control is
|
|
474
|
+
// present, and drop it. It contributes no application data and must never enter lineage.
|
|
475
|
+
visitAuxiliary(child, childType, depth + 1);
|
|
476
|
+
accessControlled = true;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
children.push(visit(child, depth + 1));
|
|
480
|
+
}
|
|
481
|
+
// Deterministic ordering so the same graph always reports the same first blocked path,
|
|
482
|
+
// regardless of the order SAP happens to serialize siblings in.
|
|
483
|
+
children.sort((a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1));
|
|
484
|
+
const dbExists = properties.get('DB_EXISTS');
|
|
485
|
+
return {
|
|
486
|
+
name,
|
|
487
|
+
aliases: [...aliases],
|
|
488
|
+
kind,
|
|
489
|
+
...(properties.get('RELATION') ? { relation: properties.get('RELATION').toUpperCase() } : {}),
|
|
490
|
+
...(dbExists !== undefined ? { databaseExists: dbExists.toUpperCase() === 'X' } : {}),
|
|
491
|
+
accessControlled,
|
|
492
|
+
children,
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
try {
|
|
496
|
+
return visit(root, 0);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
if (error instanceof DataSourceNameError) {
|
|
500
|
+
throw new DataSourceLineageError('CDS dependency graph contains an invalid technical name');
|
|
501
|
+
}
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Mask comments and quoted literals while retaining their original offsets.
|
|
507
|
+
* Authorization annotations are then found only in active DDL syntax.
|
|
508
|
+
*/
|
|
509
|
+
function scanDdlStructure(source) {
|
|
510
|
+
const structural = source.split('');
|
|
511
|
+
const strings = [];
|
|
512
|
+
let cursor = 0;
|
|
513
|
+
const mask = (index) => {
|
|
514
|
+
if (structural[index] !== '\n' && structural[index] !== '\r')
|
|
515
|
+
structural[index] = ' ';
|
|
516
|
+
};
|
|
517
|
+
while (cursor < source.length) {
|
|
518
|
+
if (source[cursor] === '/' && source[cursor + 1] === '/') {
|
|
519
|
+
mask(cursor++);
|
|
520
|
+
mask(cursor++);
|
|
521
|
+
while (cursor < source.length && source[cursor] !== '\n' && source[cursor] !== '\r')
|
|
522
|
+
mask(cursor++);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (source[cursor] === '/' && source[cursor + 1] === '*') {
|
|
526
|
+
mask(cursor++);
|
|
527
|
+
mask(cursor++);
|
|
528
|
+
let closed = false;
|
|
529
|
+
while (cursor < source.length) {
|
|
530
|
+
if (source[cursor] === '*' && source[cursor + 1] === '/') {
|
|
531
|
+
mask(cursor++);
|
|
532
|
+
mask(cursor++);
|
|
533
|
+
closed = true;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
mask(cursor++);
|
|
537
|
+
}
|
|
538
|
+
if (!closed)
|
|
539
|
+
throw new DataSourceLineageError('table DDL contains an unterminated block comment');
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (source[cursor] === "'") {
|
|
543
|
+
const start = cursor;
|
|
544
|
+
let value = '';
|
|
545
|
+
let closed = false;
|
|
546
|
+
mask(cursor++);
|
|
547
|
+
while (cursor < source.length) {
|
|
548
|
+
const char = source[cursor];
|
|
549
|
+
if (char === '\\' && cursor + 1 < source.length) {
|
|
550
|
+
value += source[cursor + 1];
|
|
551
|
+
mask(cursor++);
|
|
552
|
+
mask(cursor++);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (char === "'") {
|
|
556
|
+
mask(cursor++);
|
|
557
|
+
if (source[cursor] === "'") {
|
|
558
|
+
value += "'";
|
|
559
|
+
mask(cursor++);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
closed = true;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
value += char;
|
|
566
|
+
mask(cursor++);
|
|
567
|
+
}
|
|
568
|
+
if (!closed)
|
|
569
|
+
throw new DataSourceLineageError('table DDL contains an unterminated string literal');
|
|
570
|
+
strings.push({ start, value });
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
cursor += 1;
|
|
574
|
+
}
|
|
575
|
+
return { structural: structural.join(''), strings };
|
|
576
|
+
}
|
|
577
|
+
/** Return a table's active CDS replacement object; malformed or duplicate annotations fail closed. */
|
|
578
|
+
export function extractReplacementObject(source) {
|
|
579
|
+
const { structural, strings } = scanDdlStructure(source);
|
|
580
|
+
const markers = [...structural.matchAll(/@\s*AbapCatalog\s*\.\s*replacementObject\b/gi)];
|
|
581
|
+
if (markers.length === 0)
|
|
582
|
+
return undefined;
|
|
583
|
+
const annotations = [...structural.matchAll(/@\s*AbapCatalog\s*\.\s*replacementObject\s*:/gi)];
|
|
584
|
+
if (markers.length !== 1 || annotations.length !== 1 || markers[0].index !== annotations[0].index) {
|
|
585
|
+
throw new DataSourceLineageError('replacementObject annotation is duplicated or malformed');
|
|
586
|
+
}
|
|
587
|
+
const annotation = annotations[0];
|
|
588
|
+
const valueStart = annotation.index + annotation[0].length;
|
|
589
|
+
const valueSpan = strings.find((span) => span.start >= valueStart);
|
|
590
|
+
if (!valueSpan || structural.slice(valueStart, valueSpan.start).trim().length > 0) {
|
|
591
|
+
throw new DataSourceLineageError('replacementObject annotation is present but malformed');
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
return canonicalDataSourceName(valueSpan.value);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
throw new DataSourceLineageError('replacementObject annotation value is not an exact technical name');
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function safeLineageFailureReason(error) {
|
|
601
|
+
if (error instanceof DataSourceLineageError)
|
|
602
|
+
return error.message;
|
|
603
|
+
if (error instanceof AdtApiError) {
|
|
604
|
+
return `SAP metadata request failed with HTTP ${error.statusCode} during lineage resolution`;
|
|
605
|
+
}
|
|
606
|
+
if (error instanceof AdtNetworkError) {
|
|
607
|
+
return 'SAP metadata request failed because the backend was unreachable during lineage resolution';
|
|
608
|
+
}
|
|
609
|
+
return 'lineage metadata resolution failed without safe diagnostic details';
|
|
610
|
+
}
|
|
611
|
+
function unresolved(directSource, sourcePath, reason) {
|
|
612
|
+
return new DataSourcePolicyError('DATA_LINEAGE_UNRESOLVED', directSource, sourcePath, reason);
|
|
613
|
+
}
|
|
614
|
+
/** Evaluate direct sources and their active lineage before a data-preview request is sent. */
|
|
615
|
+
export async function enforceBlockedDataSources(directSources, configuredBlockedSources, resolver) {
|
|
616
|
+
if (configuredBlockedSources.length === 0)
|
|
617
|
+
return;
|
|
618
|
+
let blocked;
|
|
619
|
+
let roots;
|
|
620
|
+
try {
|
|
621
|
+
blocked = new Set(configuredBlockedSources.map((name) => canonicalDataSourceName(name)));
|
|
622
|
+
roots = [...new Set(directSources.map((name) => canonicalDataSourceName(name)))];
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
throw unresolved('UNKNOWN', [], error instanceof Error ? error.message : String(error));
|
|
626
|
+
}
|
|
627
|
+
if (roots.length === 0)
|
|
628
|
+
throw unresolved('UNKNOWN', [], 'no direct source was supplied');
|
|
629
|
+
const replacementCache = new Map();
|
|
630
|
+
const activeResolution = new Set();
|
|
631
|
+
const checkBlocked = (directSource, path, aliases) => {
|
|
632
|
+
const matched = aliases.find((alias) => blocked.has(alias));
|
|
633
|
+
if (matched) {
|
|
634
|
+
throw new DataSourcePolicyError('DATA_SOURCE_BLOCKED', directSource, path, `exact source ${matched} matches SAP_BLOCKED_DATA_SOURCES`, { matchedSource: matched });
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
// Preflight every root before resolving any of them. A blocked source in the
|
|
638
|
+
// second arm of a join/union must not be preceded by metadata traffic for the
|
|
639
|
+
// first source: direct matches have a stronger zero-SAP-call contract.
|
|
640
|
+
for (const root of roots)
|
|
641
|
+
checkBlocked(root, [root], [root]);
|
|
642
|
+
if (roots.length > MAX_DIRECT_SOURCES) {
|
|
643
|
+
throw unresolved(roots[0], [roots[0]], `request exceeds direct-source limit ${MAX_DIRECT_SOURCES}`);
|
|
644
|
+
}
|
|
645
|
+
const replacementFor = async (table) => {
|
|
646
|
+
if (replacementCache.has(table))
|
|
647
|
+
return replacementCache.get(table);
|
|
648
|
+
if (replacementCache.size >= MAX_REPLACEMENT_SOURCES) {
|
|
649
|
+
throw new DataSourceLineageError(`replacement inspection exceeds source limit ${MAX_REPLACEMENT_SOURCES}`);
|
|
650
|
+
}
|
|
651
|
+
const replacement = extractReplacementObject(await resolver.readTableSource(table));
|
|
652
|
+
replacementCache.set(table, replacement);
|
|
653
|
+
return replacement;
|
|
654
|
+
};
|
|
655
|
+
const replacementAt = async (directSource, table, path) => {
|
|
656
|
+
try {
|
|
657
|
+
return await replacementFor(table);
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (error instanceof DataSourcePolicyError)
|
|
661
|
+
throw error;
|
|
662
|
+
throw unresolved(directSource, path, safeLineageFailureReason(error));
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const evaluateResolved = async (directSource, resolved, path) => {
|
|
666
|
+
checkBlocked(directSource, path, [canonicalDataSourceName(resolved.name)]);
|
|
667
|
+
if (resolved.kind === 'table') {
|
|
668
|
+
const table = canonicalDataSourceName(resolved.name);
|
|
669
|
+
const replacement = await replacementAt(directSource, table, path);
|
|
670
|
+
if (replacement)
|
|
671
|
+
await evaluateRoot(directSource, replacement, [...path, replacement]);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (resolved.kind !== 'cds') {
|
|
675
|
+
throw unresolved(directSource, path, `source kind ${resolved.kind} has no proven lineage in the experimental policy`);
|
|
676
|
+
}
|
|
677
|
+
const graph = await resolver.readCdsDependencyGraph(canonicalDataSourceName(resolved.ddlSource));
|
|
678
|
+
const normalizedGraphAliases = graph.aliases.map((alias) => canonicalDataSourceName(alias));
|
|
679
|
+
if (!normalizedGraphAliases.includes(canonicalDataSourceName(resolved.name))) {
|
|
680
|
+
throw unresolved(directSource, path, `dependency graph root ${graph.name} does not identify requested CDS source ${resolved.name}`);
|
|
681
|
+
}
|
|
682
|
+
const graphPath = graph.name === path.at(-1) ? path : [...path, graph.name];
|
|
683
|
+
const validateGraph = (node, nodePath, isRoot) => {
|
|
684
|
+
checkBlocked(directSource, nodePath, node.aliases.map((alias) => canonicalDataSourceName(alias)));
|
|
685
|
+
if (node.databaseExists === false) {
|
|
686
|
+
throw unresolved(directSource, nodePath, `dependency ${node.name} is not active in the database`);
|
|
687
|
+
}
|
|
688
|
+
if (node.kind === 'TABLE') {
|
|
689
|
+
if (node.children.length > 0)
|
|
690
|
+
throw unresolved(directSource, nodePath, `table node ${node.name} has unexpected children`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (node.kind === 'CDS_TABLE_FUNCTION') {
|
|
694
|
+
// Live 758 confirms SAP emits TYPE=CDS_TABLE_FUNCTION and does NOT expand the AMDP USING
|
|
695
|
+
// list, so the node's real data sources are simply absent from the graph. Refusing on the
|
|
696
|
+
// declared kind is the primary boundary; it is never inferred from a later failed read.
|
|
697
|
+
throw unresolved(directSource, nodePath, `CDS table function ${node.name} is not supported by the experimental policy: SAP does not expose its AMDP USING lineage in the dependency graph`);
|
|
698
|
+
}
|
|
699
|
+
if (node.kind !== 'CDS_VIEW') {
|
|
700
|
+
throw unresolved(directSource, nodePath, `dependency kind ${node.kind} is unsupported`);
|
|
701
|
+
}
|
|
702
|
+
if (node.children.length === 0) {
|
|
703
|
+
throw unresolved(directSource, nodePath, `${isRoot ? 'CDS root' : 'CDS dependency'} ${node.name} has no proven terminal source`);
|
|
704
|
+
}
|
|
705
|
+
for (const child of node.children)
|
|
706
|
+
validateGraph(child, [...nodePath, child.name], false);
|
|
707
|
+
};
|
|
708
|
+
// Check the complete graph before any table-source reads. This preserves the
|
|
709
|
+
// strongest denial on old releases where canonical replacement metadata may
|
|
710
|
+
// be unavailable for an earlier sibling table.
|
|
711
|
+
validateGraph(graph, graphPath, true);
|
|
712
|
+
const expandReplacements = async (node, nodePath) => {
|
|
713
|
+
if (node.kind === 'TABLE') {
|
|
714
|
+
const replacement = await replacementAt(directSource, node.name, nodePath);
|
|
715
|
+
if (replacement)
|
|
716
|
+
await evaluateRoot(directSource, replacement, [...nodePath, replacement]);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
for (const child of node.children)
|
|
720
|
+
await expandReplacements(child, [...nodePath, child.name]);
|
|
721
|
+
};
|
|
722
|
+
await expandReplacements(graph, graphPath);
|
|
723
|
+
};
|
|
724
|
+
const evaluateRoot = async (directSource, name, path) => {
|
|
725
|
+
checkBlocked(directSource, path, [name]);
|
|
726
|
+
if (activeResolution.size >= MAX_RESOLUTION_ROOTS) {
|
|
727
|
+
throw unresolved(directSource, path, `replacement lineage exceeds depth limit ${MAX_RESOLUTION_ROOTS}`);
|
|
728
|
+
}
|
|
729
|
+
const cycleKey = `${directSource}\u0000${name}`;
|
|
730
|
+
if (activeResolution.has(cycleKey))
|
|
731
|
+
throw unresolved(directSource, path, `replacement cycle detected at ${name}`);
|
|
732
|
+
activeResolution.add(cycleKey);
|
|
733
|
+
try {
|
|
734
|
+
const resolved = await resolver.resolveDirectSource(name);
|
|
735
|
+
await evaluateResolved(directSource, resolved, path);
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
activeResolution.delete(cycleKey);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
for (const root of roots) {
|
|
742
|
+
try {
|
|
743
|
+
await evaluateRoot(root, root, [root]);
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
if (error instanceof DataSourcePolicyError)
|
|
747
|
+
throw error;
|
|
748
|
+
throw unresolved(root, [root], safeLineageFailureReason(error));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
//# sourceMappingURL=data-source-policy.js.map
|