cap-mcp-guard 0.1.0 → 0.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 +38 -1
- package/lib/adapters/odata.js +366 -0
- package/lib/core/edm.js +241 -0
- package/lib/core/odata-batch.js +161 -0
- package/lib/core/odata.js +178 -0
- package/lib/index.js +2 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -96,6 +96,44 @@ entities:
|
|
|
96
96
|
|
|
97
97
|
All three run independently and can each be disabled per-call (`audit: false`, `otel: false`) if you're wiring `registerCapMcpGuard` yourself instead of relying on auto-discovery.
|
|
98
98
|
|
|
99
|
+
## OData
|
|
100
|
+
|
|
101
|
+
`registerCapMcpGuard` hooks CAP's own `before`/`after` service events, which only exists inside a Node CAP process. A lot of business data reaches an MCP agent over OData without ever passing through those hooks — a plain OData V2/V4 service, a CAP Java service, or an on-prem SAP Gateway/S/4HANA system fronted by a proxy route. `odataMcpGuard` runs the same Context → Decision → mask/audit/OTel pipeline against raw OData HTTP traffic instead, using one `cap-mcp-guard.yaml`:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
const express = require('express');
|
|
105
|
+
const { odataMcpGuard } = require('cap-mcp-guard');
|
|
106
|
+
|
|
107
|
+
const app = express();
|
|
108
|
+
app.use('/odata', odataMcpGuard(), proxyToYourODataBackend);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- Mount it in front of any route that responds via `res.json(body)` — your own OData handler, or a reverse-proxy route rewriting a remote OData service's response.
|
|
112
|
+
- Entity names are resolved from the URL path (`/odata/v4/browse/Books(201)` → `Books`), so `cap-mcp-guard.yaml` keys entities by the same name whichever adapter is in front of them. HTTP methods map onto the same operation vocabulary CAP requests use (`GET`→`READ`, `POST`→`CREATE`, `PUT`/`PATCH`→`UPDATE`, `DELETE`→`DELETE`), so `allowTools` rules are portable too.
|
|
113
|
+
- **`$expand` is masked too.** `?$expand=author` on a `Books` read pulls the related `Author` row inline — without any extra config, its fields are masked under an `author` entity entry; nested `$expand=genre($expand=parent)` is masked at every level. One audit/OTel entry is produced per entity in the response (root + each expanded nav), not just one per HTTP request.
|
|
114
|
+
- **Pass real `$metadata` for exact entity resolution.** By default, entity names are guessed from the URL/nav-property text itself (`Books(201)/author` → `author`, not the real target entity). Pass `metadataXml` (an already-fetched `$metadata` EDMX/CSDL document — this module never fetches it itself) or a pre-parsed `edm` (see `lib/core/edm.js`), and both deep paths and `$expand` resolve through the real navigation properties instead (`Books(201)/author` → `Authors`):
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
const metadataXml = fs.readFileSync('./service.edmx', 'utf8'); // or fetch it once at startup
|
|
118
|
+
app.use('/odata', odataMcpGuard({ metadataXml }), proxyToYourODataBackend);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- **`$batch` is decoded too, both wire formats** — each sub-request inside a batch is masked/audited/traced individually, exactly like a standalone request:
|
|
122
|
+
- **OData V4 JSON batch** (`{ requests: [...] }` request / `{ responses: [...] }` response) needs `req.body` to already be the parsed request JSON — mount a JSON body-parser (`express.json()`) before this middleware.
|
|
123
|
+
- **Classic OData V2 `multipart/mixed` batch** (including changesets — grouped write operations) needs `req.body` to already be the *raw* request body, and goes out via `res.send(rawBody)` rather than `res.json()`:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
const express = require('express');
|
|
127
|
+
app.use(
|
|
128
|
+
'/sap/opu/odata',
|
|
129
|
+
express.raw({ type: 'multipart/mixed' }), // populates req.body for $batch requests
|
|
130
|
+
odataMcpGuard(),
|
|
131
|
+
proxyToYourODataBackend
|
|
132
|
+
);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
If a batch can't be decoded either way (missing/wrong `req.body`, no boundary, malformed MIME), it still produces one Context/Decision/audit entry (with an undefined entity) so it isn't silently unaccounted for — it's just unmasked.
|
|
136
|
+
|
|
99
137
|
## Try it
|
|
100
138
|
|
|
101
139
|
A full working example lives in [`examples/bookshop`](examples/bookshop) — SAP's own CAP getting-started sample, with `cap-mcp-guard` wired in and a `cap-mcp-guard.yaml` masking real fields on `CatalogService.Books`.
|
|
@@ -113,7 +151,6 @@ npm start # boots a real server at localhost:4004 — flip cap-mcp-guard.yaml
|
|
|
113
151
|
- `@mcp.policy`-style CDS annotations as an alternative to `cap-mcp-guard.yaml`
|
|
114
152
|
- Approval workflows (human-in-the-loop for sensitive operations)
|
|
115
153
|
- Rate limiting and a dashboard UI
|
|
116
|
-
- Adapters for frameworks other than CAP
|
|
117
154
|
- Actually blocking a request when `allowTools`/`maxRows` is violated (today those are computed and reported, not enforced)
|
|
118
155
|
|
|
119
156
|
## Development
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const { buildContext } = require('../core/context');
|
|
6
|
+
const { resolveEntityPath, resolveOperation, isBatchRequest, collectMaskableGroups } = require('../core/odata');
|
|
7
|
+
const { parseMetadata, parseExpandOption } = require('../core/edm');
|
|
8
|
+
const { extractBoundary, parseBatchRequests, parseBatchResponses, serializeBatchResponses } = require('../core/odata-batch');
|
|
9
|
+
const { evaluate } = require('../policy/evaluator');
|
|
10
|
+
const { maskFields } = require('../policy/masking');
|
|
11
|
+
const { loadConfig } = require('../policy/config');
|
|
12
|
+
const { logAudit } = require('../audit/log');
|
|
13
|
+
const { exportSpan } = require('../otel/exporter');
|
|
14
|
+
|
|
15
|
+
const CONFIG_FILE_NAME = 'cap-mcp-guard.yaml';
|
|
16
|
+
const NOT_FOUND_PREFIX = `${CONFIG_FILE_NAME} not found`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loads cap-mcp-guard.yaml from `cwd` — same convention and same file
|
|
20
|
+
* lib/adapters/cap.js reads, so one policy file covers both a CAP app's
|
|
21
|
+
* own service layer and any OData traffic this middleware fronts. A
|
|
22
|
+
* missing config is a valid "not configured yet" state (pass-through); a
|
|
23
|
+
* config that exists but fails to parse propagates (see cap.js for the
|
|
24
|
+
* same reasoning).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} [cwd] defaults to process.cwd()
|
|
27
|
+
*/
|
|
28
|
+
function resolvePolicyDefinition(cwd) {
|
|
29
|
+
const configPath = path.join(cwd || process.cwd(), CONFIG_FILE_NAME);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return loadConfig(configPath);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.message.startsWith(NOT_FOUND_PREFIX)) {
|
|
35
|
+
console.warn('[cap-mcp-guard] no config found, running in pass-through mode (no policies enforced)');
|
|
36
|
+
return { mode: 'observe', entities: {} };
|
|
37
|
+
}
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* OData has no MCP-runtime `_meta` bag to read (that's a CAP/CDS-side
|
|
44
|
+
* convention) — the wire-level equivalent is plain HTTP headers, which any
|
|
45
|
+
* proxying MCP runtime can set alongside a request it forwards.
|
|
46
|
+
*/
|
|
47
|
+
function extractTraceContext(req) {
|
|
48
|
+
const headers = (req && req.headers) || {};
|
|
49
|
+
return { traceparent: headers.traceparent, tracestate: headers.tracestate };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractAgentInfo(req) {
|
|
53
|
+
const headers = (req && req.headers) || {};
|
|
54
|
+
return {
|
|
55
|
+
agentId: headers['x-gen-ai-agent-id'],
|
|
56
|
+
agentName: headers['x-gen-ai-agent-name'],
|
|
57
|
+
model: headers['x-gen-ai-request-model']
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getExpandTree(url) {
|
|
62
|
+
const query = (url || '').split('?')[1];
|
|
63
|
+
if (!query) return {};
|
|
64
|
+
|
|
65
|
+
const expand = new URLSearchParams(query).get('$expand');
|
|
66
|
+
return parseExpandOption(expand);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Same in-place-mutation strategy as lib/core/interceptor.js's applyMask:
|
|
71
|
+
* maskFields() itself stays pure, this is the one place that turns its
|
|
72
|
+
* (immutable) output into a real effect on the rows already referenced
|
|
73
|
+
* inside the response body (see extractResultRows / collectMaskableGroups).
|
|
74
|
+
*/
|
|
75
|
+
function applyMask(rows, fieldsToMask) {
|
|
76
|
+
const masked = maskFields(rows, fieldsToMask);
|
|
77
|
+
|
|
78
|
+
rows.forEach((row, i) => {
|
|
79
|
+
if (!row || typeof row !== 'object') return;
|
|
80
|
+
for (const field of fieldsToMask) {
|
|
81
|
+
if (Object.prototype.hasOwnProperty.call(row, field)) {
|
|
82
|
+
row[field] = masked[i][field];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds an Express-compatible middleware `(req, res, next)` that runs the
|
|
90
|
+
* same Context → Decision → mask/audit/OTel pipeline as
|
|
91
|
+
* lib/adapters/cap.js, driven off raw OData HTTP request/response instead
|
|
92
|
+
* of CAP's before()/after() hooks. Mount it in front of ANY OData V2/V4
|
|
93
|
+
* traffic — a hand-rolled OData service, a proxy route to SAP Gateway or
|
|
94
|
+
* S/4HANA, a CAP Java service, or this same CAP Node app's own OData
|
|
95
|
+
* endpoint — regardless of what produced it, since it only ever looks at
|
|
96
|
+
* the HTTP request/response, never at a CDS model.
|
|
97
|
+
*
|
|
98
|
+
* Entity resolution is URL-path-based (lib/core/odata.js's
|
|
99
|
+
* resolveEntityPath()). Without `metadataXml`/`edm`, it's a last-path-
|
|
100
|
+
* segment heuristic, same as before; pass either one to resolve deep
|
|
101
|
+
* paths (`Books(201)/author`) and `$expand`'ed nested entities
|
|
102
|
+
* (`?$expand=author`) against the real EDM, so masking reaches nested
|
|
103
|
+
* data too, not just the top-level rows.
|
|
104
|
+
*
|
|
105
|
+
* `$batch` requests are decoded on BOTH wire formats, each sub-request
|
|
106
|
+
* masked/audited/traced individually exactly like a standalone request:
|
|
107
|
+
* - OData V4 JSON batch (`{ requests: [...] }` request body /
|
|
108
|
+
* `{ responses: [...] }` response body) — requires `req.body` to
|
|
109
|
+
* already be the parsed JSON request (i.e. a JSON body-parser like
|
|
110
|
+
* `express.json()` ran before this middleware).
|
|
111
|
+
* - Classic OData V2 `multipart/mixed` batch (see lib/core/odata-batch.js)
|
|
112
|
+
* — requires `req.body` to already be the raw request Buffer/string
|
|
113
|
+
* (e.g. `express.raw({ type: 'multipart/mixed' })` ran before this
|
|
114
|
+
* middleware) and goes out via `res.send(rawBody)`, not `res.json()`.
|
|
115
|
+
* If a batch can't be decoded either way (wrong/missing `req.body`,
|
|
116
|
+
* missing boundary, malformed MIME), it still produces one Context/
|
|
117
|
+
* Decision/audit entry (with an undefined entity) so it isn't silently
|
|
118
|
+
* unaccounted for — it's just unmasked.
|
|
119
|
+
*
|
|
120
|
+
* Response bodies are only inspected when the downstream handler calls
|
|
121
|
+
* `res.json(body)` (always) or, for a batch request specifically,
|
|
122
|
+
* `res.send(rawBody)` too — this middleware wraps those methods for the
|
|
123
|
+
* life of the request. If `res.json` isn't a function (a bare Node `http`
|
|
124
|
+
* response, or a framework that never provides it), the middleware warns
|
|
125
|
+
* once and passes every request through unguarded rather than throwing.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} [options]
|
|
128
|
+
* @param {object} [options.policyDefinition] see lib/policy/config.js —
|
|
129
|
+
* when omitted, loaded from cap-mcp-guard.yaml in options.cwd
|
|
130
|
+
* @param {string} [options.cwd] directory cap-mcp-guard.yaml is resolved
|
|
131
|
+
* from when policyDefinition isn't supplied — defaults to process.cwd()
|
|
132
|
+
* @param {string} [options.metadataXml] a `$metadata` EDMX/CSDL document
|
|
133
|
+
* (already fetched — this module never makes network calls itself),
|
|
134
|
+
* parsed once via lib/core/edm.js's parseMetadata()
|
|
135
|
+
* @param {object} [options.edm] an already-parsed EDM (see
|
|
136
|
+
* lib/core/edm.js parseMetadata()) — takes precedence over metadataXml
|
|
137
|
+
* @param {(context: object, req: object) => void} [options.onContext]
|
|
138
|
+
* called with the built context after each request completes
|
|
139
|
+
* @param {object|false} [options.audit] forwarded to logAudit()'s write
|
|
140
|
+
* options (`{ stdout, filePath }`); pass `false` to disable audit
|
|
141
|
+
* logging entirely
|
|
142
|
+
* @param {object|false} [options.otel] forwarded to exportSpan()'s options
|
|
143
|
+
* (`{ tracer }`); pass `false` to skip OTel span export entirely
|
|
144
|
+
* @param {(decision: object, context: object, req: object) => void} [options.onDecision]
|
|
145
|
+
* called in addition to the built-in audit log and OTel export, not
|
|
146
|
+
* instead of them
|
|
147
|
+
* @param {() => string} [options.now] injectable clock, forwarded to buildContext
|
|
148
|
+
* @returns {(req: object, res: object, next: Function) => void}
|
|
149
|
+
*/
|
|
150
|
+
function odataMcpGuard(options = {}) {
|
|
151
|
+
const policyDefinition = options.policyDefinition || resolvePolicyDefinition(options.cwd);
|
|
152
|
+
const { onContext, onDecision: userOnDecision, audit, otel, now } = options;
|
|
153
|
+
const edm = options.edm || (options.metadataXml && parseMetadata(options.metadataXml));
|
|
154
|
+
const clockDeps = now ? { now } : {};
|
|
155
|
+
|
|
156
|
+
let warnedNoJson = false;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The full Context → Decision → mask/audit/OTel pipeline for one logical
|
|
160
|
+
* request — either the request as a whole, or (for a decoded $batch)
|
|
161
|
+
* one of its sub-requests. Mutates `rows` in place when enforcing.
|
|
162
|
+
*/
|
|
163
|
+
function runPipeline({ req, entity, operation, rows, rowCount, durationMs }) {
|
|
164
|
+
const context = buildContext(
|
|
165
|
+
{
|
|
166
|
+
...extractAgentInfo(req),
|
|
167
|
+
...extractTraceContext(req),
|
|
168
|
+
entity,
|
|
169
|
+
operation,
|
|
170
|
+
tenant: req.headers && req.headers['sap-tenantid'],
|
|
171
|
+
user: req.user && (req.user.id || req.user.name),
|
|
172
|
+
session: req.headers && req.headers['x-request-id'],
|
|
173
|
+
rowCount,
|
|
174
|
+
durationMs
|
|
175
|
+
},
|
|
176
|
+
clockDeps
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (typeof onContext === 'function') onContext(context, req);
|
|
180
|
+
if (!policyDefinition) return;
|
|
181
|
+
|
|
182
|
+
const decision = evaluate(context, policyDefinition);
|
|
183
|
+
|
|
184
|
+
if (decision.mode === 'enforce' && decision.fieldsToMask.length > 0) {
|
|
185
|
+
applyMask(rows, decision.fieldsToMask);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (audit !== false) logAudit(context, decision, audit);
|
|
189
|
+
if (otel !== false) exportSpan(context, decision, otel);
|
|
190
|
+
if (typeof userOnDecision === 'function') userOnDecision(decision, context, req);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Runs one sub-request's worth of the pipeline, grouping $expand-ed nested entities too. */
|
|
194
|
+
function runSubRequestPipeline(req, url, method, responseBody, durationMs) {
|
|
195
|
+
const entity = resolveEntityPath(url, edm);
|
|
196
|
+
const operation = resolveOperation(method);
|
|
197
|
+
const expandTree = getExpandTree(url);
|
|
198
|
+
const groups = collectMaskableGroups(responseBody, entity, expandTree, edm);
|
|
199
|
+
|
|
200
|
+
if (groups.length === 0) {
|
|
201
|
+
runPipeline({ req, entity, operation, rows: [], rowCount: responseBody === undefined ? undefined : 0, durationMs });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const group of groups) {
|
|
206
|
+
runPipeline({ req, entity: group.entity, operation, rows: group.rows, rowCount: group.rows.length, durationMs });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Decodes and guards an OData V4 JSON batch: `req.body.requests` (each
|
|
212
|
+
* `{ id, method, url, ... }`) matched by `id` to `body.responses` (each
|
|
213
|
+
* `{ id, status, body, ... }`), so every sub-request is resolved,
|
|
214
|
+
* evaluated, and masked exactly like a standalone request would be.
|
|
215
|
+
* Returns false (nothing decoded — caller falls back to legacy
|
|
216
|
+
* pass-through) when either side isn't shaped like a V4 JSON batch.
|
|
217
|
+
*/
|
|
218
|
+
function runJsonBatchPipeline(req, body, durationMs) {
|
|
219
|
+
const requests = req.body && Array.isArray(req.body.requests) ? req.body.requests : undefined;
|
|
220
|
+
const responses = body && Array.isArray(body.responses) ? body.responses : undefined;
|
|
221
|
+
if (!requests || !responses) return false;
|
|
222
|
+
|
|
223
|
+
const requestById = new Map(requests.map((r) => [r.id, r]));
|
|
224
|
+
|
|
225
|
+
for (const responseEntry of responses) {
|
|
226
|
+
const subRequest = requestById.get(responseEntry.id);
|
|
227
|
+
if (!subRequest || !subRequest.url) continue;
|
|
228
|
+
|
|
229
|
+
runSubRequestPipeline(req, subRequest.url, subRequest.method, responseEntry.body, durationMs);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Zips a parsed batch request tree to its response tree by position —
|
|
237
|
+
* each top-level (or changeset-nested) request corresponds to exactly
|
|
238
|
+
* one response in the same slot, per OData $batch semantics. Mismatched
|
|
239
|
+
* shapes (a 'single' lined up against a 'changeset', or a length
|
|
240
|
+
* mismatch) are skipped rather than crashing the response.
|
|
241
|
+
*/
|
|
242
|
+
function zipClassicBatch(req, requestItems, responseItems, durationMs) {
|
|
243
|
+
const len = Math.min(requestItems.length, responseItems.length);
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < len; i++) {
|
|
246
|
+
const reqItem = requestItems[i];
|
|
247
|
+
const resItem = responseItems[i];
|
|
248
|
+
|
|
249
|
+
if (reqItem.kind === 'changeset' && resItem.kind === 'changeset') {
|
|
250
|
+
zipClassicBatch(req, reqItem.parts, resItem.parts, durationMs);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (reqItem.kind !== 'single' || resItem.kind !== 'single') continue;
|
|
254
|
+
|
|
255
|
+
let parsedBody;
|
|
256
|
+
try {
|
|
257
|
+
parsedBody = resItem.body ? JSON.parse(resItem.body) : undefined;
|
|
258
|
+
} catch {
|
|
259
|
+
parsedBody = undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
runSubRequestPipeline(req, reqItem.url, reqItem.method, parsedBody, durationMs);
|
|
263
|
+
if (parsedBody !== undefined) resItem.body = JSON.stringify(parsedBody);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Decodes and guards a classic OData V2 `multipart/mixed` $batch: parses
|
|
269
|
+
* `req.body` (the raw request body — a JSON body-parser won't populate
|
|
270
|
+
* this for a multipart content-type, so the host needs something like
|
|
271
|
+
* `express.raw({ type: 'multipart/mixed' })` mounted first) and the raw
|
|
272
|
+
* response body about to be sent, zips requests to responses by
|
|
273
|
+
* position, masks each response's JSON in place, and returns the
|
|
274
|
+
* re-serialized response body. Returns false (caller falls back to
|
|
275
|
+
* legacy pass-through) when either side can't be decoded as multipart —
|
|
276
|
+
* missing boundary, `req.body` not pre-buffered, or malformed MIME.
|
|
277
|
+
*/
|
|
278
|
+
function runClassicBatchPipeline(req, res, rawResponseBody, durationMs) {
|
|
279
|
+
const reqBoundary = extractBoundary(req.headers && req.headers['content-type']);
|
|
280
|
+
const resBoundary = extractBoundary(typeof res.getHeader === 'function' ? res.getHeader('content-type') : undefined);
|
|
281
|
+
|
|
282
|
+
const rawRequestBody = req.body;
|
|
283
|
+
const requestIsBufferLike = typeof rawRequestBody === 'string' || Buffer.isBuffer(rawRequestBody);
|
|
284
|
+
const responseIsBufferLike = typeof rawResponseBody === 'string' || Buffer.isBuffer(rawResponseBody);
|
|
285
|
+
|
|
286
|
+
if (!reqBoundary || !resBoundary || !requestIsBufferLike || !responseIsBufferLike) return false;
|
|
287
|
+
|
|
288
|
+
let requestItems;
|
|
289
|
+
let responseItems;
|
|
290
|
+
try {
|
|
291
|
+
requestItems = parseBatchRequests(rawRequestBody, reqBoundary);
|
|
292
|
+
responseItems = parseBatchResponses(rawResponseBody, resBoundary);
|
|
293
|
+
} catch {
|
|
294
|
+
return false; // malformed multipart — don't crash the response, fall back to legacy pass-through
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
zipClassicBatch(req, requestItems, responseItems, durationMs);
|
|
298
|
+
return serializeBatchResponses(responseItems, resBoundary);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return function odataMcpGuardMiddleware(req, res, next) {
|
|
302
|
+
if (typeof res.json !== 'function') {
|
|
303
|
+
if (!warnedNoJson) {
|
|
304
|
+
console.warn('[cap-mcp-guard] res.json is not available; OData responses will pass through unguarded');
|
|
305
|
+
warnedNoJson = true;
|
|
306
|
+
}
|
|
307
|
+
return next();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const startedAt = process.hrtime.bigint();
|
|
311
|
+
const operation = resolveOperation(req.method);
|
|
312
|
+
const batch = isBatchRequest(req.url);
|
|
313
|
+
const originalJson = res.json.bind(res);
|
|
314
|
+
|
|
315
|
+
// Shared between the res.json and res.send patches below: Express's
|
|
316
|
+
// real res.json() (captured as originalJson) internally calls
|
|
317
|
+
// this.send(...) — since that resolves to OUR patched res.send when
|
|
318
|
+
// `batch` is true, this flag stops the batch already handled by
|
|
319
|
+
// guardedJson from being processed a second time by guardedSend.
|
|
320
|
+
let handled = false;
|
|
321
|
+
|
|
322
|
+
res.json = function guardedJson(body) {
|
|
323
|
+
if (handled) return originalJson(body);
|
|
324
|
+
handled = true;
|
|
325
|
+
|
|
326
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
327
|
+
|
|
328
|
+
if (batch) {
|
|
329
|
+
if (!runJsonBatchPipeline(req, body, durationMs)) {
|
|
330
|
+
// classic multipart/mixed batch that didn't go through res.send,
|
|
331
|
+
// or req.body wasn't pre-parsed: still audited, nothing masked
|
|
332
|
+
runPipeline({ req, entity: undefined, operation, rows: [], rowCount: undefined, durationMs });
|
|
333
|
+
}
|
|
334
|
+
return originalJson(body);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
runSubRequestPipeline(req, req.url, req.method, body, durationMs);
|
|
338
|
+
return originalJson(body);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Classic multipart/mixed $batch responses go out via res.send(rawBody),
|
|
342
|
+
// never res.json() — only patch res.send for batch requests, so the
|
|
343
|
+
// (already tested) non-batch path never touches it.
|
|
344
|
+
if (batch && typeof res.send === 'function') {
|
|
345
|
+
const originalSend = res.send.bind(res);
|
|
346
|
+
|
|
347
|
+
res.send = function guardedSend(bodyChunk) {
|
|
348
|
+
if (handled) return originalSend(bodyChunk);
|
|
349
|
+
handled = true;
|
|
350
|
+
|
|
351
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
352
|
+
const newBody = runClassicBatchPipeline(req, res, bodyChunk, durationMs);
|
|
353
|
+
|
|
354
|
+
if (newBody !== false) return originalSend(newBody);
|
|
355
|
+
|
|
356
|
+
// couldn't decode as classic multipart batch either: still audited
|
|
357
|
+
runPipeline({ req, entity: undefined, operation, rows: [], rowCount: undefined, durationMs });
|
|
358
|
+
return originalSend(bodyChunk);
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
next();
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
module.exports = { odataMcpGuard };
|
package/lib/core/edm.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A hand-rolled, regex-based reader for OData `$metadata` documents (EDMX/
|
|
5
|
+
* CSDL XML, V2 or V4) — not a general-purpose XML/EDM implementation, just
|
|
6
|
+
* enough structure (entity sets, entity types, navigation properties) to
|
|
7
|
+
* resolve a URL path or a `$expand` nav-property name down to the entity
|
|
8
|
+
* name cap-mcp-guard.yaml keys entities by. Good enough for the
|
|
9
|
+
* well-formed $metadata documents SAP/CAP services actually emit.
|
|
10
|
+
*
|
|
11
|
+
* Pure — no I/O. Fetching `$metadata` over the network is the caller's
|
|
12
|
+
* responsibility (see lib/adapters/odata.js's `metadataXml`/`edm` options);
|
|
13
|
+
* keeping that out of this module means it stays synchronous and
|
|
14
|
+
* dependency-free.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function matchAll(pattern, text) {
|
|
18
|
+
const results = [];
|
|
19
|
+
const re = new RegExp(pattern, 'g');
|
|
20
|
+
let m;
|
|
21
|
+
while ((m = re.exec(text))) results.push(m);
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getAttr(tagAttrs, name) {
|
|
26
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(tagAttrs);
|
|
27
|
+
return m ? m[1] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unwrapCollection(type) {
|
|
31
|
+
const m = /^Collection\(([^)]+)\)$/.exec(type || '');
|
|
32
|
+
return m ? { target: m[1], isCollection: true } : { target: type, isCollection: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unqualifiedName(fqName) {
|
|
36
|
+
if (!fqName) return fqName;
|
|
37
|
+
const idx = fqName.lastIndexOf('.');
|
|
38
|
+
return idx === -1 ? fqName : fqName.slice(idx + 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parses an EDMX/CSDL XML string into a minimal EDM model.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} xml
|
|
45
|
+
* @returns {{
|
|
46
|
+
* entityTypes: Record<string, { navigationProperties: Record<string, { target: string, isCollection?: boolean }> }>,
|
|
47
|
+
* entitySets: Record<string, string>,
|
|
48
|
+
* entityTypeToSet: Record<string, string>
|
|
49
|
+
* }}
|
|
50
|
+
*/
|
|
51
|
+
function parseMetadata(xml) {
|
|
52
|
+
const entityTypes = {};
|
|
53
|
+
const entitySets = {};
|
|
54
|
+
const entityTypeToSet = {};
|
|
55
|
+
|
|
56
|
+
const schemaBlocks = matchAll('<Schema\\b([^>]*)>([\\s\\S]*?)</Schema>', xml);
|
|
57
|
+
|
|
58
|
+
for (const schemaMatch of schemaBlocks) {
|
|
59
|
+
const namespace = getAttr(schemaMatch[1], 'Namespace');
|
|
60
|
+
const schemaBody = schemaMatch[2];
|
|
61
|
+
if (!namespace) continue;
|
|
62
|
+
|
|
63
|
+
// V2 only: NavigationProperty indirects through Relationship/ToRole to
|
|
64
|
+
// an <Association>'s <End Role="..." Type="...">. V4 NavigationProperty
|
|
65
|
+
// carries its target Type directly, no association needed.
|
|
66
|
+
const associations = {};
|
|
67
|
+
for (const assocMatch of matchAll('<Association\\b[^>]*Name="([^"]*)"[^>]*>([\\s\\S]*?)</Association>', schemaBody)) {
|
|
68
|
+
const fqName = `${namespace}.${assocMatch[1]}`;
|
|
69
|
+
const roles = {};
|
|
70
|
+
for (const endMatch of matchAll('<End\\b([^>]*?)/?>', assocMatch[2])) {
|
|
71
|
+
const role = getAttr(endMatch[1], 'Role');
|
|
72
|
+
const type = getAttr(endMatch[1], 'Type');
|
|
73
|
+
if (role && type) roles[role] = type;
|
|
74
|
+
}
|
|
75
|
+
associations[fqName] = roles;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const entityTypeMatch of matchAll('<EntityType\\b([^>]*)>([\\s\\S]*?)</EntityType>', schemaBody)) {
|
|
79
|
+
const localName = getAttr(entityTypeMatch[1], 'Name');
|
|
80
|
+
if (!localName) continue;
|
|
81
|
+
const fqName = `${namespace}.${localName}`;
|
|
82
|
+
const body = entityTypeMatch[2];
|
|
83
|
+
|
|
84
|
+
const navigationProperties = {};
|
|
85
|
+
for (const navMatch of matchAll('<NavigationProperty\\b([^>]*?)/?>', body)) {
|
|
86
|
+
const attrs = navMatch[1];
|
|
87
|
+
const navName = getAttr(attrs, 'Name');
|
|
88
|
+
if (!navName) continue;
|
|
89
|
+
|
|
90
|
+
const typeAttr = getAttr(attrs, 'Type');
|
|
91
|
+
if (typeAttr) {
|
|
92
|
+
navigationProperties[navName] = unwrapCollection(typeAttr);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const relationship = getAttr(attrs, 'Relationship');
|
|
97
|
+
const toRole = getAttr(attrs, 'ToRole');
|
|
98
|
+
const target = relationship && toRole && associations[relationship] && associations[relationship][toRole];
|
|
99
|
+
if (target) {
|
|
100
|
+
navigationProperties[navName] = { target, isCollection: undefined };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
entityTypes[fqName] = { navigationProperties };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const setMatch of matchAll('<EntitySet\\b([^>]*?)/?>', schemaBody)) {
|
|
108
|
+
const attrs = setMatch[1];
|
|
109
|
+
const setName = getAttr(attrs, 'Name');
|
|
110
|
+
const entityType = getAttr(attrs, 'EntityType');
|
|
111
|
+
if (!setName || !entityType) continue;
|
|
112
|
+
|
|
113
|
+
entitySets[setName] = entityType;
|
|
114
|
+
if (!entityTypeToSet[entityType]) entityTypeToSet[entityType] = setName;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { entityTypes, entitySets, entityTypeToSet };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** An entity set name when one maps to this type, else its bare local name. */
|
|
122
|
+
function toDisplayName(edm, fqTypeName) {
|
|
123
|
+
return (edm.entityTypeToSet && edm.entityTypeToSet[fqTypeName]) || unqualifiedName(fqTypeName);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves already-split, already-`(key)`-stripped, $-segment-free URL path
|
|
128
|
+
* segments against a parsed EDM, walking navigation properties hop by hop
|
|
129
|
+
* from the first segment that matches a known entity set (segments before
|
|
130
|
+
* that — a service mount path like `odata/v4/browse` — are ignored, so
|
|
131
|
+
* this works regardless of where the service is mounted).
|
|
132
|
+
*
|
|
133
|
+
* Falls back to the last path segment (matching lib/core/odata.js's
|
|
134
|
+
* no-metadata resolveEntitySet() heuristic) when no segment matches a
|
|
135
|
+
* known entity set, and to the raw segment name at any hop whose nav
|
|
136
|
+
* property isn't described in the EDM — a `$metadata` document that
|
|
137
|
+
* doesn't fully describe every segment shouldn't break resolution for the
|
|
138
|
+
* segments it DOES know about.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} edm see parseMetadata()
|
|
141
|
+
* @param {string[]} segments
|
|
142
|
+
* @returns {string|undefined}
|
|
143
|
+
*/
|
|
144
|
+
function resolveEntityForPath(edm, segments) {
|
|
145
|
+
if (!segments.length) return undefined;
|
|
146
|
+
|
|
147
|
+
const startIndex = segments.findIndex((s) => Object.prototype.hasOwnProperty.call(edm.entitySets, s));
|
|
148
|
+
if (startIndex === -1) return segments[segments.length - 1];
|
|
149
|
+
|
|
150
|
+
let currentType = edm.entitySets[segments[startIndex]];
|
|
151
|
+
let displayName = toDisplayName(edm, currentType);
|
|
152
|
+
|
|
153
|
+
for (let i = startIndex + 1; i < segments.length; i++) {
|
|
154
|
+
const entityType = currentType && edm.entityTypes[currentType];
|
|
155
|
+
const nav = entityType && entityType.navigationProperties[segments[i]];
|
|
156
|
+
|
|
157
|
+
if (!nav) {
|
|
158
|
+
displayName = segments[i];
|
|
159
|
+
currentType = undefined;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
currentType = nav.target;
|
|
164
|
+
displayName = toDisplayName(edm, currentType);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return displayName;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolves a `$expand`'ed navigation-property name to its target entity's
|
|
172
|
+
* policy-config-friendly name, given the (already-resolved) entity name of
|
|
173
|
+
* the row it hangs off of. Falls back to the raw nav-property name when
|
|
174
|
+
* the parent entity or the nav property itself isn't described in the EDM.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} edm
|
|
177
|
+
* @param {string} parentEntityName as returned by resolveEntityForPath() or
|
|
178
|
+
* a previous resolveExpandTarget() call
|
|
179
|
+
* @param {string} navPropName
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
function resolveExpandTarget(edm, parentEntityName, navPropName) {
|
|
183
|
+
const parentType =
|
|
184
|
+
edm.entitySets[parentEntityName] ||
|
|
185
|
+
Object.keys(edm.entityTypes).find((fq) => toDisplayName(edm, fq) === parentEntityName);
|
|
186
|
+
|
|
187
|
+
const entityType = parentType && edm.entityTypes[parentType];
|
|
188
|
+
const nav = entityType && entityType.navigationProperties[navPropName];
|
|
189
|
+
|
|
190
|
+
return nav ? toDisplayName(edm, nav.target) : navPropName;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parses an OData `$expand` query-option value into a tree of nav-property
|
|
195
|
+
* names to recurse into — e.g. `"author,genre($expand=parent)"` becomes
|
|
196
|
+
* `{ author: {}, genre: { parent: {} } }`. Only a nested `$expand=` option
|
|
197
|
+
* is honored inside a parenthesized nav property; other nested options
|
|
198
|
+
* (`$select`, `$filter`, ...) don't change which nested entities show up
|
|
199
|
+
* in the response body, so they're ignored.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} expandParam raw (already URL-decoded) $expand value
|
|
202
|
+
* @returns {object} nested tree, `{}` for an empty/missing value
|
|
203
|
+
*/
|
|
204
|
+
function parseExpandOption(expandParam) {
|
|
205
|
+
const tree = {};
|
|
206
|
+
if (!expandParam) return tree;
|
|
207
|
+
|
|
208
|
+
const parts = [];
|
|
209
|
+
let depth = 0;
|
|
210
|
+
let start = 0;
|
|
211
|
+
for (let i = 0; i < expandParam.length; i++) {
|
|
212
|
+
const ch = expandParam[i];
|
|
213
|
+
if (ch === '(') depth++;
|
|
214
|
+
else if (ch === ')') depth--;
|
|
215
|
+
else if (ch === ',' && depth === 0) {
|
|
216
|
+
parts.push(expandParam.slice(start, i));
|
|
217
|
+
start = i + 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
parts.push(expandParam.slice(start));
|
|
221
|
+
|
|
222
|
+
for (const rawPart of parts) {
|
|
223
|
+
const part = rawPart.trim();
|
|
224
|
+
if (!part) continue;
|
|
225
|
+
|
|
226
|
+
const parenIdx = part.indexOf('(');
|
|
227
|
+
if (parenIdx === -1) {
|
|
228
|
+
tree[part] = tree[part] || {};
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const navName = part.slice(0, parenIdx).trim();
|
|
233
|
+
const inner = part.slice(parenIdx + 1, part.lastIndexOf(')'));
|
|
234
|
+
const nestedExpandMatch = /\$expand=([^;]*)/.exec(inner);
|
|
235
|
+
tree[navName] = parseExpandOption(nestedExpandMatch ? nestedExpandMatch[1] : '');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return tree;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { parseMetadata, resolveEntityForPath, resolveExpandTarget, parseExpandOption };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A minimal reader/writer for the classic OData V2 `$batch` wire format:
|
|
5
|
+
* a `multipart/mixed` body whose parts are either a single embedded HTTP
|
|
6
|
+
* request/response (`Content-Type: application/http`) or a nested
|
|
7
|
+
* `multipart/mixed` "changeset" grouping several write operations
|
|
8
|
+
* atomically. Not a general-purpose MIME implementation — just enough
|
|
9
|
+
* structure to zip a batch's requests to its responses by position and
|
|
10
|
+
* mask each response's JSON body in place.
|
|
11
|
+
*
|
|
12
|
+
* Responses are re-serialized canonically (consistent CRLF line endings,
|
|
13
|
+
* consistent header ordering per part) rather than byte-for-byte
|
|
14
|
+
* preserved — that's syntactically equivalent multipart/mixed, which is
|
|
15
|
+
* all any compliant OData client relies on; it does not reproduce the
|
|
16
|
+
* exact original bytes.
|
|
17
|
+
*
|
|
18
|
+
* Pure — no I/O, no HTTP framework dependency.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function toText(value) {
|
|
22
|
+
return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} contentType a Content-Type header value
|
|
27
|
+
* @returns {string|undefined} the `boundary` parameter, unquoted
|
|
28
|
+
*/
|
|
29
|
+
function extractBoundary(contentType) {
|
|
30
|
+
const m = /boundary=("?)([^;"]+)\1/i.exec(contentType || '');
|
|
31
|
+
return m ? m[2] : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Splits a multipart body into its raw part strings, boundary markers stripped. */
|
|
35
|
+
function splitMultipart(rawBody, boundary) {
|
|
36
|
+
const text = toText(rawBody);
|
|
37
|
+
const marker = `--${boundary}`;
|
|
38
|
+
const segments = text.split(marker);
|
|
39
|
+
|
|
40
|
+
const parts = [];
|
|
41
|
+
for (let i = 1; i < segments.length; i++) {
|
|
42
|
+
const segment = segments[i];
|
|
43
|
+
if (segment.startsWith('--')) break; // the closing "--boundary--" marker
|
|
44
|
+
|
|
45
|
+
const trimmed = segment.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
|
46
|
+
if (trimmed.length) parts.push(trimmed);
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitHeadersAndBody(text) {
|
|
52
|
+
const m = /\r?\n\r?\n/.exec(text);
|
|
53
|
+
if (!m) return { headerBlock: text, body: '' };
|
|
54
|
+
return { headerBlock: text.slice(0, m.index), body: text.slice(m.index + m[0].length) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseHeaderBlock(headerBlock) {
|
|
58
|
+
const headers = {};
|
|
59
|
+
for (const line of headerBlock.split(/\r?\n/)) {
|
|
60
|
+
const m = /^([^:]+):\s*(.*)$/.exec(line);
|
|
61
|
+
if (m) headers[m[1].trim().toLowerCase()] = m[2].trim();
|
|
62
|
+
}
|
|
63
|
+
return headers;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One embedded `application/http` part: a request-line/status-line, headers, and a body. */
|
|
67
|
+
function parseHttpEnvelope(text, { isResponse }) {
|
|
68
|
+
const { headerBlock, body } = splitHeadersAndBody(text);
|
|
69
|
+
const [firstLine, ...headerLines] = headerBlock.split(/\r?\n/);
|
|
70
|
+
|
|
71
|
+
if (isResponse) {
|
|
72
|
+
const m = /^HTTP\/[\d.]+\s+(\d+)\s*(.*)$/.exec((firstLine || '').trim());
|
|
73
|
+
return {
|
|
74
|
+
kind: 'single',
|
|
75
|
+
status: m ? Number(m[1]) : 200,
|
|
76
|
+
statusText: m ? m[2].trim() : 'OK',
|
|
77
|
+
headerLines: headerLines.filter((l) => l.trim().length > 0),
|
|
78
|
+
body
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const m = /^(\S+)\s+(\S+)\s+HTTP\/[\d.]+$/.exec((firstLine || '').trim());
|
|
83
|
+
return {
|
|
84
|
+
kind: 'single',
|
|
85
|
+
method: m ? m[1] : undefined,
|
|
86
|
+
url: m ? m[2] : undefined,
|
|
87
|
+
headerLines: headerLines.filter((l) => l.trim().length > 0),
|
|
88
|
+
body
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parses one top-level (or changeset-nested) batch part, recursing into changesets. */
|
|
93
|
+
function parseBatchPart(rawPart, { isResponse }) {
|
|
94
|
+
const { headerBlock, body } = splitHeadersAndBody(rawPart);
|
|
95
|
+
const headers = parseHeaderBlock(headerBlock);
|
|
96
|
+
const contentType = headers['content-type'] || '';
|
|
97
|
+
|
|
98
|
+
if (/multipart\/mixed/i.test(contentType)) {
|
|
99
|
+
const nestedBoundary = extractBoundary(contentType);
|
|
100
|
+
return {
|
|
101
|
+
kind: 'changeset',
|
|
102
|
+
boundary: nestedBoundary,
|
|
103
|
+
parts: nestedBoundary ? splitMultipart(body, nestedBoundary).map((p) => parseBatchPart(p, { isResponse })) : []
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return parseHttpEnvelope(body, { isResponse });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string|Buffer} rawBody the $batch request body
|
|
112
|
+
* @param {string} boundary from the request's Content-Type header
|
|
113
|
+
* @returns {object[]} tree of `{ kind: 'single', method, url, ... } | { kind: 'changeset', boundary, parts }`
|
|
114
|
+
*/
|
|
115
|
+
function parseBatchRequests(rawBody, boundary) {
|
|
116
|
+
return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: false }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string|Buffer} rawBody the $batch response body
|
|
121
|
+
* @param {string} boundary from the response's Content-Type header
|
|
122
|
+
* @returns {object[]} tree of `{ kind: 'single', status, statusText, headerLines, body } | { kind: 'changeset', boundary, parts }`
|
|
123
|
+
*/
|
|
124
|
+
function parseBatchResponses(rawBody, boundary) {
|
|
125
|
+
return splitMultipart(rawBody, boundary).map((p) => parseBatchPart(p, { isResponse: true }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function serializeEnvelope(envelope) {
|
|
129
|
+
const startLine = `HTTP/1.1 ${envelope.status} ${envelope.statusText}`;
|
|
130
|
+
const headerText = envelope.headerLines.map((l) => `${l}\r\n`).join('');
|
|
131
|
+
return `${startLine}\r\n${headerText}\r\n${envelope.body}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Re-serializes a (possibly mutated) response tree — see parseBatchResponses()
|
|
136
|
+
* — back into a valid `multipart/mixed` body using `boundary`.
|
|
137
|
+
*
|
|
138
|
+
* @param {object[]} items as returned by parseBatchResponses()
|
|
139
|
+
* @param {string} boundary
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function serializeBatchResponses(items, boundary) {
|
|
143
|
+
let out = '';
|
|
144
|
+
|
|
145
|
+
for (const item of items) {
|
|
146
|
+
out += `--${boundary}\r\n`;
|
|
147
|
+
|
|
148
|
+
if (item.kind === 'changeset') {
|
|
149
|
+
out += `Content-Type: multipart/mixed; boundary=${item.boundary}\r\n\r\n`;
|
|
150
|
+
out += serializeBatchResponses(item.parts, item.boundary);
|
|
151
|
+
} else {
|
|
152
|
+
out += `Content-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n\r\n`;
|
|
153
|
+
out += `${serializeEnvelope(item)}\r\n`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
out += `--${boundary}--\r\n`;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { extractBoundary, splitMultipart, parseBatchRequests, parseBatchResponses, serializeBatchResponses };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveEntityForPath, resolveExpandTarget } = require('./edm');
|
|
4
|
+
|
|
5
|
+
const ODATA_SYSTEM_SEGMENTS = new Set(['$metadata', '$batch']);
|
|
6
|
+
|
|
7
|
+
const METHOD_TO_OPERATION = {
|
|
8
|
+
GET: 'READ',
|
|
9
|
+
POST: 'CREATE',
|
|
10
|
+
PUT: 'UPDATE',
|
|
11
|
+
PATCH: 'UPDATE',
|
|
12
|
+
MERGE: 'UPDATE',
|
|
13
|
+
DELETE: 'DELETE'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the OData entity-set (or navigation property) name a request
|
|
18
|
+
* targets, from its URL path alone — no service metadata ($metadata/CSDL)
|
|
19
|
+
* is consulted. Works for both OData V2 (`/sap/opu/odata/sap/ZGW_SRV/Products`)
|
|
20
|
+
* and V4 (`/odata/v4/browse/Books(201)`) path conventions: it's the last
|
|
21
|
+
* path segment, with any `(key)` predicate stripped.
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined for OData system resources ($metadata, $batch) since
|
|
24
|
+
* those don't map to a single configurable entity.
|
|
25
|
+
*
|
|
26
|
+
* Pure function, no HTTP framework dependency.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} url request path or full URL (query string is ignored)
|
|
29
|
+
* @returns {string|undefined}
|
|
30
|
+
*/
|
|
31
|
+
function resolveEntitySet(url) {
|
|
32
|
+
if (!url) return undefined;
|
|
33
|
+
|
|
34
|
+
const pathOnly = url.split('?')[0];
|
|
35
|
+
const segments = pathOnly.split('/').filter(Boolean);
|
|
36
|
+
if (segments.length === 0) return undefined;
|
|
37
|
+
|
|
38
|
+
const last = segments[segments.length - 1].replace(/\(.*$/, '');
|
|
39
|
+
if (!last || ODATA_SYSTEM_SEGMENTS.has(last) || last.startsWith('$')) return undefined;
|
|
40
|
+
|
|
41
|
+
return last;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maps an HTTP method onto the same CQN-style operation vocabulary the
|
|
46
|
+
* guard already uses for CAP requests (READ/CREATE/UPDATE/DELETE, see
|
|
47
|
+
* lib/adapters/cap.js's resolveOperation) — so one cap-mcp-guard.yaml
|
|
48
|
+
* applies unchanged whether traffic arrives via CAP's before/after hooks
|
|
49
|
+
* or raw OData HTTP.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} method
|
|
52
|
+
* @returns {string|undefined}
|
|
53
|
+
*/
|
|
54
|
+
function resolveOperation(method) {
|
|
55
|
+
return METHOD_TO_OPERATION[(method || '').toUpperCase()];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} url
|
|
60
|
+
* @returns {boolean} true for a $batch request (OData V2 and V4 both use
|
|
61
|
+
* this convention). Its body is a multipart mix of sub-requests this
|
|
62
|
+
* module does not decode, so per-entity masking does not reach inside it
|
|
63
|
+
* — see README "Limitations".
|
|
64
|
+
*/
|
|
65
|
+
function isBatchRequest(url) {
|
|
66
|
+
if (!url) return false;
|
|
67
|
+
const pathOnly = url.split('?')[0];
|
|
68
|
+
return pathOnly.split('/').filter(Boolean).pop() === '$batch';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extracts the row objects an OData JSON response body carries, regardless
|
|
73
|
+
* of whether it's a V2 collection (`{ d: { results: [...] } }`), a V2
|
|
74
|
+
* single entity (`{ d: {...} }`), a V4 collection (`{ value: [...] }`), or
|
|
75
|
+
* a V4 single entity (a bare entity object carrying `@odata.context`).
|
|
76
|
+
*
|
|
77
|
+
* The returned rows are the SAME object references found inside `body` —
|
|
78
|
+
* mutating a returned row mutates the response body in place, mirroring
|
|
79
|
+
* how CAP's `after` handlers mutate results in place (see
|
|
80
|
+
* lib/core/interceptor.js's applyMask).
|
|
81
|
+
*
|
|
82
|
+
* @param {*} body parsed JSON response body
|
|
83
|
+
* @returns {object[]} row objects (empty array if the shape is unrecognized)
|
|
84
|
+
*/
|
|
85
|
+
function extractResultRows(body) {
|
|
86
|
+
if (!body || typeof body !== 'object') return [];
|
|
87
|
+
|
|
88
|
+
if (Array.isArray(body.value)) return body.value;
|
|
89
|
+
if (body.d && Array.isArray(body.d.results)) return body.d.results;
|
|
90
|
+
if (body.d && typeof body.d === 'object') return [body.d];
|
|
91
|
+
if ('@odata.context' in body) return [body];
|
|
92
|
+
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolves the FULL request path (every segment, not just the last) into
|
|
98
|
+
* a policy-config-friendly entity name. Without `edm`, this is exactly
|
|
99
|
+
* resolveEntitySet()'s single-last-segment heuristic — unchanged behavior.
|
|
100
|
+
* With `edm` (see lib/core/edm.js's parseMetadata()), the whole path is
|
|
101
|
+
* walked hop-by-hop through navigation properties, so a deep path like
|
|
102
|
+
* `Books(201)/author` resolves to `author`'s real target entity instead
|
|
103
|
+
* of the literal string `"author"`.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} url
|
|
106
|
+
* @param {object} [edm] see lib/core/edm.js parseMetadata()
|
|
107
|
+
* @returns {string|undefined}
|
|
108
|
+
*/
|
|
109
|
+
function resolveEntityPath(url, edm) {
|
|
110
|
+
if (!edm) return resolveEntitySet(url);
|
|
111
|
+
if (!url) return undefined;
|
|
112
|
+
|
|
113
|
+
const pathOnly = url.split('?')[0];
|
|
114
|
+
const segments = pathOnly
|
|
115
|
+
.split('/')
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.map((s) => s.replace(/\(.*$/, ''))
|
|
118
|
+
.filter((s) => s && !ODATA_SYSTEM_SEGMENTS.has(s) && !s.startsWith('$'));
|
|
119
|
+
|
|
120
|
+
return resolveEntityForPath(edm, segments);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function nestedRowsFor(row, navName) {
|
|
124
|
+
const value = row && typeof row === 'object' ? row[navName] : undefined;
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(value)) return value;
|
|
127
|
+
if (value && Array.isArray(value.results)) return value.results; // OData V2 expand shape
|
|
128
|
+
if (value && typeof value === 'object' && !('__deferred' in value)) return [value];
|
|
129
|
+
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Walks a parsed OData response body and groups every row it contains —
|
|
135
|
+
* the top-level collection/entity plus every `$expand`'ed nested
|
|
136
|
+
* entity/collection reachable through `expandTree` — by the entity name
|
|
137
|
+
* masking rules should be looked up under. Each group's rows are the SAME
|
|
138
|
+
* object references found inside `body` (see extractResultRows), so
|
|
139
|
+
* masking a group's rows mutates the response body in place.
|
|
140
|
+
*
|
|
141
|
+
* Without an `edm`, a nested entity's name is just its nav-property name
|
|
142
|
+
* as it appears in `$expand` — good enough when cap-mcp-guard.yaml happens
|
|
143
|
+
* to key an entity by that same name, but not a real entity-set/type
|
|
144
|
+
* resolution. Pass `edm` (see lib/core/edm.js) to resolve nav properties
|
|
145
|
+
* to their real target entity instead.
|
|
146
|
+
*
|
|
147
|
+
* @param {*} body parsed JSON response body
|
|
148
|
+
* @param {string} rootEntity entity name for the top-level rows
|
|
149
|
+
* @param {object} [expandTree] see lib/core/edm.js parseExpandOption()
|
|
150
|
+
* @param {object} [edm] see lib/core/edm.js parseMetadata()
|
|
151
|
+
* @returns {{ entity: string, rows: object[] }[]}
|
|
152
|
+
*/
|
|
153
|
+
function collectMaskableGroups(body, rootEntity, expandTree = {}, edm) {
|
|
154
|
+
const groups = [];
|
|
155
|
+
|
|
156
|
+
function walk(rows, entityName, tree) {
|
|
157
|
+
if (rows.length) groups.push({ entity: entityName, rows });
|
|
158
|
+
|
|
159
|
+
for (const [navName, subTree] of Object.entries(tree)) {
|
|
160
|
+
const nestedEntity = edm ? resolveExpandTarget(edm, entityName, navName) : navName;
|
|
161
|
+
const nestedRows = rows.flatMap((row) => nestedRowsFor(row, navName));
|
|
162
|
+
|
|
163
|
+
walk(nestedRows, nestedEntity, subTree);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
walk(extractResultRows(body), rootEntity, expandTree);
|
|
168
|
+
return groups;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
resolveEntitySet,
|
|
173
|
+
resolveOperation,
|
|
174
|
+
isBatchRequest,
|
|
175
|
+
extractResultRows,
|
|
176
|
+
resolveEntityPath,
|
|
177
|
+
collectMaskableGroups
|
|
178
|
+
};
|
package/lib/index.js
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const { buildContext } = require('./core/context');
|
|
4
4
|
const { attachInterceptor } = require('./core/interceptor');
|
|
5
|
+
const { odataMcpGuard } = require('./adapters/odata');
|
|
5
6
|
|
|
6
|
-
module.exports = { buildContext, attachInterceptor };
|
|
7
|
+
module.exports = { buildContext, attachInterceptor, odataMcpGuard };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cap-mcp-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The trust layer for AI agents accessing SAP CAP business data — request interception, policy enforcement, field masking, and OpenTelemetry-native observability for MCP-enabled applications.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"author": "Furkan Aksoy <furkanaksy838@gmail.com>",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"keywords": [
|
|
23
23
|
"cap",
|
|
24
24
|
"sap",
|
|
25
|
+
"odata",
|
|
25
26
|
"mcp",
|
|
26
27
|
"ai-agent",
|
|
27
28
|
"policy",
|