cap-mcp-guard 0.2.0 → 0.3.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 +229 -163
- package/lib/adapters/cap.js +99 -79
- package/lib/core/interceptor.js +32 -1
- package/lib/index.js +1 -2
- package/lib/policy/config.js +164 -106
- package/lib/policy/evaluator.js +26 -11
- package/lib/policy/pseudonym.js +89 -0
- package/package.json +56 -54
- package/lib/adapters/odata.js +0 -366
- package/lib/core/edm.js +0 -241
- package/lib/core/odata-batch.js +0 -161
- package/lib/core/odata.js +0 -178
package/lib/core/odata-batch.js
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
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 };
|
package/lib/core/odata.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
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
|
-
};
|