@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.
Files changed (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -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"; we treat it as "default" and
31
- * leave the explicit disable path on the roadmap)
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
- limit;
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.limit = cfg.limit ?? DEFAULT_LIMIT_PER_MIN;
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
- if (fresh.length >= this.limit) {
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: this.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: this.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.limit, windowMs: this.windowMs };
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
+ }