@thotischner/observability-mcp 1.8.1 → 3.0.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/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +1188 -120
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +1729 -100
- package/package.json +13 -3
package/dist/quota/limiter.js
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
const DEFAULT_LIMIT_PER_MIN = 60;
|
|
20
20
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
21
|
+
/** Magic strings that explicitly disable the per-identity cap.
|
|
22
|
+
* Matched case-insensitively. Operators picked any of these to
|
|
23
|
+
* mean "no rate limit at all" — useful when the caps are enforced
|
|
24
|
+
* upstream (envoy / API-gateway) and OMCP shouldn't double-count. */
|
|
25
|
+
const UNLIMITED_TOKENS = new Set(["off", "none", "unlimited", "disabled", "false"]);
|
|
21
26
|
/** Resolve `OMCP_TOOL_RATE_PER_MIN` (or any equivalent caller-supplied
|
|
22
27
|
* string) into the per-identity cap used by the limiter and reported
|
|
23
28
|
* by `/api/info` + `/api/usage`. Single source of truth, so the three
|
|
@@ -27,8 +32,14 @@ const DEFAULT_WINDOW_MS = 60_000;
|
|
|
27
32
|
* - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
|
|
28
33
|
* - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
|
|
29
34
|
* which is almost never what an operator setting "0" wants — they
|
|
30
|
-
* either mean "default" or "disable";
|
|
31
|
-
*
|
|
35
|
+
* either mean "default" or "disable"; this function maps it to the
|
|
36
|
+
* default so they aren't accidentally locked out, and the explicit
|
|
37
|
+
* disable path is one of the UNLIMITED_TOKENS instead)
|
|
38
|
+
* - `"off"` / `"none"` / `"unlimited"` / `"disabled"` / `"false"`
|
|
39
|
+
* (case-insensitive) → Number.POSITIVE_INFINITY, which the
|
|
40
|
+
* `count >= limit` comparison in check() always allows. JSON
|
|
41
|
+
* serialisation renders Infinity as `null`; consumers can treat
|
|
42
|
+
* a null limit as "uncapped".
|
|
32
43
|
* - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
|
|
33
44
|
* `count >= limit` check would also deny every request)
|
|
34
45
|
* - any positive integer ≥ 1 → that value
|
|
@@ -36,19 +47,63 @@ const DEFAULT_WINDOW_MS = 60_000;
|
|
|
36
47
|
export function resolveToolRatePerMin(raw) {
|
|
37
48
|
if (raw === undefined || raw === "")
|
|
38
49
|
return DEFAULT_LIMIT_PER_MIN;
|
|
50
|
+
if (UNLIMITED_TOKENS.has(raw.trim().toLowerCase()))
|
|
51
|
+
return Number.POSITIVE_INFINITY;
|
|
39
52
|
const n = Number(raw);
|
|
40
53
|
if (!Number.isFinite(n) || n < 1)
|
|
41
54
|
return DEFAULT_LIMIT_PER_MIN;
|
|
42
55
|
return Math.floor(n);
|
|
43
56
|
}
|
|
57
|
+
/** Parse OMCP_KEY_RATE_PER_MIN — `name=count;name2=count2`. Same
|
|
58
|
+
* shape as parseKeyTenants / parseKeyProducts so operators have one
|
|
59
|
+
* syntactic model across all per-credential overrides. Unknown
|
|
60
|
+
* counts (non-numeric / ≤ 0) silently skip. Magic disable tokens
|
|
61
|
+
* (off/none/unlimited/disabled/false) map to Infinity, same as the
|
|
62
|
+
* global OMCP_TOOL_RATE_PER_MIN. */
|
|
63
|
+
export function parseKeyRateLimits(raw) {
|
|
64
|
+
const m = new Map();
|
|
65
|
+
if (!raw)
|
|
66
|
+
return m;
|
|
67
|
+
for (const entry of raw.split(";").map((s) => s.trim()).filter(Boolean)) {
|
|
68
|
+
const eq = entry.indexOf("=");
|
|
69
|
+
if (eq <= 0)
|
|
70
|
+
continue;
|
|
71
|
+
const name = entry.slice(0, eq).trim();
|
|
72
|
+
const valueRaw = entry.slice(eq + 1).trim();
|
|
73
|
+
if (!name || !valueRaw)
|
|
74
|
+
continue;
|
|
75
|
+
if (UNLIMITED_TOKENS.has(valueRaw.toLowerCase())) {
|
|
76
|
+
m.set(name, Number.POSITIVE_INFINITY);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const n = Number(valueRaw);
|
|
80
|
+
if (!Number.isFinite(n) || n < 1)
|
|
81
|
+
continue;
|
|
82
|
+
m.set(name, Math.floor(n));
|
|
83
|
+
}
|
|
84
|
+
return m;
|
|
85
|
+
}
|
|
44
86
|
export class IdentityRateLimiter {
|
|
45
|
-
|
|
87
|
+
defaultLimit;
|
|
46
88
|
windowMs;
|
|
89
|
+
limitFor;
|
|
47
90
|
// identity → ring of millisecond timestamps, newest at the end.
|
|
48
91
|
buckets = new Map();
|
|
49
92
|
constructor(cfg = {}) {
|
|
50
|
-
this.
|
|
93
|
+
this.defaultLimit = cfg.limit ?? DEFAULT_LIMIT_PER_MIN;
|
|
51
94
|
this.windowMs = cfg.windowMs ?? DEFAULT_WINDOW_MS;
|
|
95
|
+
this.limitFor = cfg.limitFor;
|
|
96
|
+
}
|
|
97
|
+
/** Resolved cap for one identity: the per-identity override wins
|
|
98
|
+
* when defined; otherwise the process-wide default applies. */
|
|
99
|
+
resolveLimit(identity) {
|
|
100
|
+
if (this.limitFor) {
|
|
101
|
+
const v = this.limitFor(identity);
|
|
102
|
+
if (typeof v === "number" && (Number.isFinite(v) ? v >= 1 : v === Number.POSITIVE_INFINITY)) {
|
|
103
|
+
return v;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return this.defaultLimit;
|
|
52
107
|
}
|
|
53
108
|
/** Record-and-test a call for the given identity. Returns the
|
|
54
109
|
* decision plus enough context to render a 429 with Retry-After. */
|
|
@@ -60,7 +115,8 @@ export class IdentityRateLimiter {
|
|
|
60
115
|
while (i < bucket.length && bucket[i] <= cutoff)
|
|
61
116
|
i++;
|
|
62
117
|
const fresh = i === 0 ? bucket : bucket.slice(i);
|
|
63
|
-
|
|
118
|
+
const limit = this.resolveLimit(identity);
|
|
119
|
+
if (fresh.length >= limit) {
|
|
64
120
|
// Compute when the oldest in-window record drops off.
|
|
65
121
|
const retryAfterMs = fresh[0] + this.windowMs - now;
|
|
66
122
|
// Don't store the call we just denied — that would push the
|
|
@@ -69,7 +125,7 @@ export class IdentityRateLimiter {
|
|
|
69
125
|
return {
|
|
70
126
|
allowed: false,
|
|
71
127
|
count: fresh.length,
|
|
72
|
-
limit
|
|
128
|
+
limit,
|
|
73
129
|
windowMs: this.windowMs,
|
|
74
130
|
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
|
75
131
|
};
|
|
@@ -79,7 +135,7 @@ export class IdentityRateLimiter {
|
|
|
79
135
|
return {
|
|
80
136
|
allowed: true,
|
|
81
137
|
count: fresh.length,
|
|
82
|
-
limit
|
|
138
|
+
limit,
|
|
83
139
|
windowMs: this.windowMs,
|
|
84
140
|
retryAfterSeconds: 0,
|
|
85
141
|
};
|
|
@@ -92,7 +148,7 @@ export class IdentityRateLimiter {
|
|
|
92
148
|
for (const t of bucket)
|
|
93
149
|
if (t > cutoff)
|
|
94
150
|
count++;
|
|
95
|
-
return { count, limit: this.
|
|
151
|
+
return { count, limit: this.resolveLimit(identity), windowMs: this.windowMs };
|
|
96
152
|
}
|
|
97
153
|
/** All identities we've ever seen — for /api/usage aggregation. */
|
|
98
154
|
knownIdentities() {
|
|
@@ -117,3 +117,89 @@ test("default limit applies when constructed with no args", () => {
|
|
|
117
117
|
}
|
|
118
118
|
assert.equal(lim.check("alice", t + 60).allowed, false);
|
|
119
119
|
});
|
|
120
|
+
test("resolveToolRatePerMin — explicit-disable tokens map to Infinity (any case, with whitespace)", () => {
|
|
121
|
+
for (const tok of ["off", "OFF", "Off", "none", "NONE", "unlimited", "UNLIMITED", "disabled", "false", " off "]) {
|
|
122
|
+
assert.equal(resolveToolRatePerMin(tok), Number.POSITIVE_INFINITY, `'${tok}' should disable the limiter`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
test("IdentityRateLimiter — limit=Infinity always allows (the explicit-disable contract)", () => {
|
|
126
|
+
const lim = new IdentityRateLimiter({ limit: Number.POSITIVE_INFINITY });
|
|
127
|
+
const t = 1_700_000_000_000;
|
|
128
|
+
// Burst far past the default cap; every call must allow.
|
|
129
|
+
for (let i = 0; i < 1000; i++) {
|
|
130
|
+
const r = lim.check("alice", t + i);
|
|
131
|
+
assert.equal(r.allowed, true);
|
|
132
|
+
assert.equal(r.retryAfterSeconds, 0);
|
|
133
|
+
// Limit reflects the configured Infinity — JSON serialisation
|
|
134
|
+
// would render this as null; callers can branch on Number.isFinite.
|
|
135
|
+
assert.equal(r.limit, Number.POSITIVE_INFINITY);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
test("resolveToolRatePerMin — disable tokens are NOT a number trap (\"infinity\" alone is not a token)", () => {
|
|
139
|
+
// We deliberately do NOT accept the literal string "infinity"
|
|
140
|
+
// because Number("Infinity") === Infinity — operators expect
|
|
141
|
+
// OMCP_TOOL_RATE_PER_MIN=Infinity to error out, not silently
|
|
142
|
+
// mean "unlimited". The explicit tokens are off/none/unlimited/
|
|
143
|
+
// disabled/false. (Number.isFinite is what the resolver checks.)
|
|
144
|
+
assert.equal(resolveToolRatePerMin("Infinity"), 60, "literal 'Infinity' must NOT secretly enable unlimited mode");
|
|
145
|
+
});
|
|
146
|
+
import { parseKeyRateLimits } from "./limiter.js";
|
|
147
|
+
test("parseKeyRateLimits — parses name=count pairs, skips malformed entries", () => {
|
|
148
|
+
const m = parseKeyRateLimits("agent=600;ci=240;bad;empty=;negative=-1;zero=0;notnum=abc");
|
|
149
|
+
assert.equal(m.get("agent"), 600);
|
|
150
|
+
assert.equal(m.get("ci"), 240);
|
|
151
|
+
assert.equal(m.get("bad"), undefined);
|
|
152
|
+
assert.equal(m.get("empty"), undefined);
|
|
153
|
+
assert.equal(m.get("negative"), undefined);
|
|
154
|
+
assert.equal(m.get("zero"), undefined);
|
|
155
|
+
assert.equal(m.get("notnum"), undefined);
|
|
156
|
+
});
|
|
157
|
+
test("parseKeyRateLimits — disable tokens map to Infinity (same vocabulary as the global override)", () => {
|
|
158
|
+
const m = parseKeyRateLimits("agent=off;ci=unlimited;loud=DISABLED");
|
|
159
|
+
assert.equal(m.get("agent"), Number.POSITIVE_INFINITY);
|
|
160
|
+
assert.equal(m.get("ci"), Number.POSITIVE_INFINITY);
|
|
161
|
+
assert.equal(m.get("loud"), Number.POSITIVE_INFINITY);
|
|
162
|
+
});
|
|
163
|
+
test("IdentityRateLimiter — limitFor override wins over the default cap", () => {
|
|
164
|
+
// Default is 60; override gives agent=2.
|
|
165
|
+
const lim = new IdentityRateLimiter({
|
|
166
|
+
limit: 60,
|
|
167
|
+
limitFor: (id) => (id === "default agent" ? 2 : undefined),
|
|
168
|
+
});
|
|
169
|
+
const t = 1_700_000_000_000;
|
|
170
|
+
// Two calls allowed for agent, third denies.
|
|
171
|
+
assert.equal(lim.check("default agent", t).allowed, true);
|
|
172
|
+
assert.equal(lim.check("default agent", t + 1).allowed, true);
|
|
173
|
+
const denied = lim.check("default agent", t + 2);
|
|
174
|
+
assert.equal(denied.allowed, false);
|
|
175
|
+
assert.equal(denied.limit, 2, "reported limit must be the per-identity override, not the default");
|
|
176
|
+
// Default identity still gets the global 60.
|
|
177
|
+
for (let i = 0; i < 60; i++) {
|
|
178
|
+
assert.equal(lim.check("default other", t + i).allowed, true);
|
|
179
|
+
}
|
|
180
|
+
assert.equal(lim.check("default other", t + 60).allowed, false);
|
|
181
|
+
});
|
|
182
|
+
test("IdentityRateLimiter — limitFor returning undefined falls back to default; Infinity disables for that identity", () => {
|
|
183
|
+
const lim = new IdentityRateLimiter({
|
|
184
|
+
limit: 5,
|
|
185
|
+
limitFor: (id) => (id === "default vip" ? Number.POSITIVE_INFINITY : undefined),
|
|
186
|
+
});
|
|
187
|
+
const t = 1_700_000_000_000;
|
|
188
|
+
// VIP can burst far past 5.
|
|
189
|
+
for (let i = 0; i < 1000; i++) {
|
|
190
|
+
assert.equal(lim.check("default vip", t + i).allowed, true);
|
|
191
|
+
}
|
|
192
|
+
// Default user still capped at 5.
|
|
193
|
+
for (let i = 0; i < 5; i++) {
|
|
194
|
+
assert.equal(lim.check("default user", t + i).allowed, true);
|
|
195
|
+
}
|
|
196
|
+
assert.equal(lim.check("default user", t + 5).allowed, false);
|
|
197
|
+
});
|
|
198
|
+
test("IdentityRateLimiter — inspect() reports the per-identity limit too (not just the default)", () => {
|
|
199
|
+
const lim = new IdentityRateLimiter({
|
|
200
|
+
limit: 60,
|
|
201
|
+
limitFor: (id) => (id === "default agent" ? 600 : undefined),
|
|
202
|
+
});
|
|
203
|
+
assert.equal(lim.inspect("default agent").limit, 600);
|
|
204
|
+
assert.equal(lim.inspect("default other").limit, 60);
|
|
205
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function parseScimGroupRoleMap(raw: string | undefined): Map<string, string>;
|
|
2
|
+
/** Map a user's group-display-names to the gateway's RBAC roles.
|
|
3
|
+
* Unknown groups are silently dropped (least-privilege). */
|
|
4
|
+
export declare function rolesForGroups(groupDisplayNames: string[], map: Map<string, string>): string[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Translate SCIM-provisioned groups into the gateway's RBAC roles.
|
|
2
|
+
// Operators configure the mapping via OMCP_SCIM_GROUP_ROLE_MAP:
|
|
3
|
+
//
|
|
4
|
+
// OMCP_SCIM_GROUP_ROLE_MAP="admins:admin,sre:operator,readers:viewer"
|
|
5
|
+
//
|
|
6
|
+
// A SCIM-managed user's groups[] (populated from group membership
|
|
7
|
+
// in the ScimStore) translates to a set of RBAC roles via this map,
|
|
8
|
+
// joining the OIDC group-mapping pattern from F6 so a federated
|
|
9
|
+
// IdP rolling Users + Groups via SCIM ends up with the same RBAC
|
|
10
|
+
// posture as a directly-claim-mapped login.
|
|
11
|
+
export function parseScimGroupRoleMap(raw) {
|
|
12
|
+
const out = new Map();
|
|
13
|
+
if (!raw)
|
|
14
|
+
return out;
|
|
15
|
+
for (const pair of raw.split(",")) {
|
|
16
|
+
const [groupName, role] = pair.split(":").map((s) => s.trim());
|
|
17
|
+
if (!groupName || !role)
|
|
18
|
+
continue;
|
|
19
|
+
out.set(groupName.toLowerCase(), role);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** Map a user's group-display-names to the gateway's RBAC roles.
|
|
24
|
+
* Unknown groups are silently dropped (least-privilege). */
|
|
25
|
+
export function rolesForGroups(groupDisplayNames, map) {
|
|
26
|
+
const out = new Set();
|
|
27
|
+
for (const g of groupDisplayNames) {
|
|
28
|
+
const role = map.get(g.toLowerCase());
|
|
29
|
+
if (role)
|
|
30
|
+
out.add(role);
|
|
31
|
+
}
|
|
32
|
+
return [...out];
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseScimGroupRoleMap, rolesForGroups } from "./group-role-map.js";
|
|
4
|
+
test("parseScimGroupRoleMap: empty / undefined → empty map", () => {
|
|
5
|
+
assert.equal(parseScimGroupRoleMap(undefined).size, 0);
|
|
6
|
+
assert.equal(parseScimGroupRoleMap("").size, 0);
|
|
7
|
+
});
|
|
8
|
+
test("parseScimGroupRoleMap: comma-separated key:role pairs, lowercased keys", () => {
|
|
9
|
+
const m = parseScimGroupRoleMap("Admins:admin,SRE:operator,Readers:viewer");
|
|
10
|
+
assert.equal(m.get("admins"), "admin");
|
|
11
|
+
assert.equal(m.get("sre"), "operator");
|
|
12
|
+
assert.equal(m.get("readers"), "viewer");
|
|
13
|
+
});
|
|
14
|
+
test("parseScimGroupRoleMap: malformed entries silently dropped", () => {
|
|
15
|
+
const m = parseScimGroupRoleMap("admins:admin,no-colon,:emptyKey,validKey:validRole");
|
|
16
|
+
assert.equal(m.get("admins"), "admin");
|
|
17
|
+
assert.equal(m.get("validkey"), "validRole");
|
|
18
|
+
assert.equal(m.size, 2);
|
|
19
|
+
});
|
|
20
|
+
test("rolesForGroups: unknown groups dropped (least-privilege)", () => {
|
|
21
|
+
const map = parseScimGroupRoleMap("admins:admin,sre:operator");
|
|
22
|
+
const roles = rolesForGroups(["admins", "unknown-group"], map);
|
|
23
|
+
assert.deepEqual(roles, ["admin"]);
|
|
24
|
+
});
|
|
25
|
+
test("rolesForGroups: dedupes roles", () => {
|
|
26
|
+
const map = parseScimGroupRoleMap("admins:admin,sysadmins:admin");
|
|
27
|
+
const roles = rolesForGroups(["admins", "sysadmins"], map);
|
|
28
|
+
assert.deepEqual(roles, ["admin"]);
|
|
29
|
+
});
|
|
30
|
+
test("rolesForGroups: case-insensitive group lookup", () => {
|
|
31
|
+
const map = parseScimGroupRoleMap("Admins:admin");
|
|
32
|
+
assert.deepEqual(rolesForGroups(["ADMINS"], map), ["admin"]);
|
|
33
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Application } from "express";
|
|
2
|
+
import { ScimStore } from "./store.js";
|
|
3
|
+
export interface ScimRoutesDeps {
|
|
4
|
+
store: ScimStore;
|
|
5
|
+
bearerToken: string;
|
|
6
|
+
/** Audit hook called after every successful mutation. Best-effort. */
|
|
7
|
+
audit?: (event: {
|
|
8
|
+
actor: string;
|
|
9
|
+
action: string;
|
|
10
|
+
target: string;
|
|
11
|
+
result: "ok" | "error";
|
|
12
|
+
status: number;
|
|
13
|
+
}) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function registerScimRoutes(app: Application, deps: ScimRoutesDeps): void;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// SCIM 2.0 routes — mounted at /scim/v2/.
|
|
2
|
+
//
|
|
3
|
+
// Spec subset covered:
|
|
4
|
+
// GET /scim/v2/ServiceProviderConfig
|
|
5
|
+
// GET /scim/v2/ResourceTypes
|
|
6
|
+
// GET /scim/v2/Schemas
|
|
7
|
+
// GET /scim/v2/Users list (no filter support yet)
|
|
8
|
+
// GET /scim/v2/Users/:id
|
|
9
|
+
// POST /scim/v2/Users
|
|
10
|
+
// PATCH /scim/v2/Users/:id minimal: replace top-level attrs
|
|
11
|
+
// DELETE /scim/v2/Users/:id
|
|
12
|
+
// GET /scim/v2/Groups
|
|
13
|
+
// GET /scim/v2/Groups/:id
|
|
14
|
+
// POST /scim/v2/Groups
|
|
15
|
+
// PATCH /scim/v2/Groups/:id
|
|
16
|
+
// DELETE /scim/v2/Groups/:id
|
|
17
|
+
//
|
|
18
|
+
// Auth: Bearer token via OMCP_SCIM_TOKEN; absence of OMCP_SCIM_TOKEN
|
|
19
|
+
// rejects every request (the routes are not safe without it).
|
|
20
|
+
import { timingSafeEqual } from "node:crypto";
|
|
21
|
+
import { SCIM_SCHEMA_LIST_RESPONSE, SCIM_SCHEMA_USER, SCIM_SCHEMA_GROUP, scimError, } from "./types.js";
|
|
22
|
+
import { ScimNotFoundError, ScimValidationError } from "./store.js";
|
|
23
|
+
const constantTimeBearerMatch = (raw, expected) => {
|
|
24
|
+
if (!raw)
|
|
25
|
+
return false;
|
|
26
|
+
const m = raw.match(/^Bearer\s+(.+)$/i);
|
|
27
|
+
if (!m)
|
|
28
|
+
return false;
|
|
29
|
+
const a = Buffer.from(m[1].trim());
|
|
30
|
+
const b = Buffer.from(expected);
|
|
31
|
+
if (a.length !== b.length)
|
|
32
|
+
return false;
|
|
33
|
+
return timingSafeEqual(a, b);
|
|
34
|
+
};
|
|
35
|
+
export function registerScimRoutes(app, deps) {
|
|
36
|
+
const { store, bearerToken, audit } = deps;
|
|
37
|
+
// Auth middleware — scoped to /scim/v2/* only.
|
|
38
|
+
app.use("/scim/v2", (req, res, next) => {
|
|
39
|
+
if (!bearerToken) {
|
|
40
|
+
res.status(503).json(scimError(503, "SCIM is enabled but OMCP_SCIM_TOKEN is unset"));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!constantTimeBearerMatch(req.headers["authorization"], bearerToken)) {
|
|
44
|
+
res.status(401).json(scimError(401, "valid SCIM bearer token required"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
next();
|
|
48
|
+
});
|
|
49
|
+
// ---- Discovery endpoints ----
|
|
50
|
+
app.get("/scim/v2/ServiceProviderConfig", (_req, res) => {
|
|
51
|
+
res.json({
|
|
52
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
53
|
+
documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
|
|
54
|
+
patch: { supported: true },
|
|
55
|
+
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
|
56
|
+
filter: { supported: false, maxResults: 200 },
|
|
57
|
+
changePassword: { supported: false },
|
|
58
|
+
sort: { supported: false },
|
|
59
|
+
etag: { supported: false },
|
|
60
|
+
authenticationSchemes: [
|
|
61
|
+
{
|
|
62
|
+
name: "OAuth Bearer Token",
|
|
63
|
+
description: "Authentication via OAuth 2.0 bearer token (configured per-deployment).",
|
|
64
|
+
specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
|
|
65
|
+
documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
|
|
66
|
+
type: "oauthbearertoken",
|
|
67
|
+
primary: true,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
app.get("/scim/v2/ResourceTypes", (_req, res) => {
|
|
73
|
+
res.json({
|
|
74
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
75
|
+
totalResults: 2,
|
|
76
|
+
Resources: [
|
|
77
|
+
{
|
|
78
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
79
|
+
id: "User",
|
|
80
|
+
name: "User",
|
|
81
|
+
endpoint: "/Users",
|
|
82
|
+
description: "User account",
|
|
83
|
+
schema: SCIM_SCHEMA_USER,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
87
|
+
id: "Group",
|
|
88
|
+
name: "Group",
|
|
89
|
+
endpoint: "/Groups",
|
|
90
|
+
description: "Group / role mapping",
|
|
91
|
+
schema: SCIM_SCHEMA_GROUP,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
app.get("/scim/v2/Schemas", (_req, res) => {
|
|
97
|
+
res.json({
|
|
98
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
99
|
+
totalResults: 2,
|
|
100
|
+
Resources: [
|
|
101
|
+
{ schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"], id: SCIM_SCHEMA_USER, name: "User" },
|
|
102
|
+
{ schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"], id: SCIM_SCHEMA_GROUP, name: "Group" },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// ---- Users ----
|
|
107
|
+
app.get("/scim/v2/Users", (_req, res) => {
|
|
108
|
+
const users = store.listUsers().map((u) => withGroups(u, store));
|
|
109
|
+
res.json({
|
|
110
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
111
|
+
totalResults: users.length,
|
|
112
|
+
itemsPerPage: users.length,
|
|
113
|
+
startIndex: 1,
|
|
114
|
+
Resources: users,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
app.get("/scim/v2/Users/:id", (req, res) => {
|
|
118
|
+
const u = store.getUser(req.params.id);
|
|
119
|
+
if (!u) {
|
|
120
|
+
res.status(404).json(scimError(404, `User ${req.params.id} not found`));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.json(withGroups(u, store));
|
|
124
|
+
});
|
|
125
|
+
app.post("/scim/v2/Users", async (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const u = await store.createUser((req.body ?? {}));
|
|
128
|
+
audit?.({ actor: "scim", action: "User.create", target: u.userName, result: "ok", status: 201 });
|
|
129
|
+
res.status(201).json(withGroups(u, store));
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
handleStoreError(e, res, "User.create", audit);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
app.patch("/scim/v2/Users/:id", async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
const patch = applyPatchOps(store.getUser(req.params.id), req.body);
|
|
138
|
+
const u = await store.updateUser(req.params.id, patch);
|
|
139
|
+
audit?.({ actor: "scim", action: "User.update", target: u.userName, result: "ok", status: 200 });
|
|
140
|
+
res.json(withGroups(u, store));
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
handleStoreError(e, res, "User.update", audit);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
app.delete("/scim/v2/Users/:id", async (req, res) => {
|
|
147
|
+
const ok = await store.deleteUser(req.params.id);
|
|
148
|
+
audit?.({ actor: "scim", action: "User.delete", target: req.params.id, result: ok ? "ok" : "error", status: ok ? 204 : 404 });
|
|
149
|
+
if (!ok) {
|
|
150
|
+
res.status(404).json(scimError(404, `User ${req.params.id} not found`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
res.status(204).end();
|
|
154
|
+
});
|
|
155
|
+
// ---- Groups ----
|
|
156
|
+
app.get("/scim/v2/Groups", (_req, res) => {
|
|
157
|
+
const groups = store.listGroups();
|
|
158
|
+
res.json({
|
|
159
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
160
|
+
totalResults: groups.length,
|
|
161
|
+
itemsPerPage: groups.length,
|
|
162
|
+
startIndex: 1,
|
|
163
|
+
Resources: groups,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
app.get("/scim/v2/Groups/:id", (req, res) => {
|
|
167
|
+
const g = store.getGroup(req.params.id);
|
|
168
|
+
if (!g) {
|
|
169
|
+
res.status(404).json(scimError(404, `Group ${req.params.id} not found`));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
res.json(g);
|
|
173
|
+
});
|
|
174
|
+
app.post("/scim/v2/Groups", async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const g = await store.createGroup((req.body ?? {}));
|
|
177
|
+
audit?.({ actor: "scim", action: "Group.create", target: g.displayName, result: "ok", status: 201 });
|
|
178
|
+
res.status(201).json(g);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
handleStoreError(e, res, "Group.create", audit);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
app.patch("/scim/v2/Groups/:id", async (req, res) => {
|
|
185
|
+
try {
|
|
186
|
+
const patch = applyPatchOps(store.getGroup(req.params.id), req.body);
|
|
187
|
+
const g = await store.updateGroup(req.params.id, patch);
|
|
188
|
+
audit?.({ actor: "scim", action: "Group.update", target: g.displayName, result: "ok", status: 200 });
|
|
189
|
+
res.json(g);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
handleStoreError(e, res, "Group.update", audit);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
app.delete("/scim/v2/Groups/:id", async (req, res) => {
|
|
196
|
+
const ok = await store.deleteGroup(req.params.id);
|
|
197
|
+
audit?.({ actor: "scim", action: "Group.delete", target: req.params.id, result: ok ? "ok" : "error", status: ok ? 204 : 404 });
|
|
198
|
+
if (!ok) {
|
|
199
|
+
res.status(404).json(scimError(404, `Group ${req.params.id} not found`));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
res.status(204).end();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function withGroups(u, store) {
|
|
206
|
+
return { ...u, groups: store.groupsContaining(u.id) };
|
|
207
|
+
}
|
|
208
|
+
/** Translate a SCIM PatchOp into a partial resource patch. Minimal:
|
|
209
|
+
* we accept `op: "replace"` with no path (whole-resource merge) or
|
|
210
|
+
* with a single-segment path naming a top-level attribute. `add` and
|
|
211
|
+
* `remove` for members/emails arrays are a follow-up — the
|
|
212
|
+
* Entra/Okta provisioning checklists exercise replace-only on the
|
|
213
|
+
* attributes we expose. */
|
|
214
|
+
function applyPatchOps(current, patch) {
|
|
215
|
+
if (!current)
|
|
216
|
+
throw new ScimNotFoundError("target resource not found");
|
|
217
|
+
if (!patch?.Operations || !Array.isArray(patch.Operations))
|
|
218
|
+
return {};
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const op of patch.Operations) {
|
|
221
|
+
if (op.op !== "replace")
|
|
222
|
+
continue; // skip add/remove for F21a
|
|
223
|
+
if (!op.path) {
|
|
224
|
+
// value is a partial object — merge top-level keys
|
|
225
|
+
if (op.value && typeof op.value === "object") {
|
|
226
|
+
Object.assign(out, op.value);
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
out[op.path] = op.value;
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
function handleStoreError(e, res, action, audit) {
|
|
235
|
+
if (e instanceof ScimNotFoundError) {
|
|
236
|
+
audit?.({ actor: "scim", action, target: "?", result: "error", status: 404 });
|
|
237
|
+
res.status(404).json(scimError(404, e.message));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (e instanceof ScimValidationError) {
|
|
241
|
+
const status = e.scimType === "uniqueness" ? 409 : 400;
|
|
242
|
+
audit?.({ actor: "scim", action, target: "?", result: "error", status });
|
|
243
|
+
res.status(status).json(scimError(status, e.message, e.scimType));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
console.warn(`[scim] ${action} failed:`, e);
|
|
247
|
+
audit?.({ actor: "scim", action, target: "?", result: "error", status: 500 });
|
|
248
|
+
res.status(500).json(scimError(500, "internal error"));
|
|
249
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ScimGroup, type ScimUser } from "./types.js";
|
|
2
|
+
export interface ScimSnapshot {
|
|
3
|
+
users: ScimUser[];
|
|
4
|
+
groups: ScimGroup[];
|
|
5
|
+
}
|
|
6
|
+
export declare class ScimStore {
|
|
7
|
+
private readonly path;
|
|
8
|
+
private snapshot;
|
|
9
|
+
private bootstrapped;
|
|
10
|
+
constructor(path: string);
|
|
11
|
+
load(): Promise<void>;
|
|
12
|
+
listUsers(): ScimUser[];
|
|
13
|
+
getUser(id: string): ScimUser | undefined;
|
|
14
|
+
getUserByUserName(userName: string): ScimUser | undefined;
|
|
15
|
+
createUser(input: Partial<ScimUser>): Promise<ScimUser>;
|
|
16
|
+
updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
|
|
17
|
+
deleteUser(id: string): Promise<boolean>;
|
|
18
|
+
listGroups(): ScimGroup[];
|
|
19
|
+
getGroup(id: string): ScimGroup | undefined;
|
|
20
|
+
createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
21
|
+
updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
22
|
+
deleteGroup(id: string): Promise<boolean>;
|
|
23
|
+
/** Look up the groups a user is currently a member of — used to
|
|
24
|
+
* populate `User.groups` on read responses. */
|
|
25
|
+
groupsContaining(userId: string): Array<{
|
|
26
|
+
value: string;
|
|
27
|
+
display?: string;
|
|
28
|
+
}>;
|
|
29
|
+
private persist;
|
|
30
|
+
}
|
|
31
|
+
export declare class ScimValidationError extends Error {
|
|
32
|
+
readonly scimType?: string | undefined;
|
|
33
|
+
constructor(message: string, scimType?: string | undefined);
|
|
34
|
+
}
|
|
35
|
+
export declare class ScimNotFoundError extends Error {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|