@thotischner/observability-mcp 3.4.0 → 3.6.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/index.js CHANGED
@@ -25,6 +25,7 @@ import { resolveSessionStore } from "./transport/sessionStore.js";
25
25
  import { generateNonce, enforcedCsp, reportOnlyCsp, reportingEndpointsHeader, reportToHeader, summariseViolation, cspStrictReportFromEnv, CSP_NONCE_PLACEHOLDER, } from "./security/csp.js";
26
26
  import { createScimStore } from "./scim/store.js";
27
27
  import { registerScimRoutes } from "./scim/routes.js";
28
+ import { projectProvisioning } from "./scim/provisioning-view.js";
28
29
  import { BuiltinPolicyEngine } from "./auth/policy/engine.js";
29
30
  import { loadPolicyFromFile, writePolicyFile, PolicyLoadError, VALID_RESOURCES, VALID_ACTIONS } from "./auth/policy/loader.js";
30
31
  import { OpaPolicyEngine } from "./auth/policy/opa.js";
@@ -1692,6 +1693,14 @@ async function main() {
1692
1693
  }
1693
1694
  res.json(result);
1694
1695
  });
1696
+ // --- /api/provisioning — read-only view of SCIM-provisioned identities --
1697
+ // Set below when SCIM is enabled (OMCP_SCIM_TOKEN). Lets the dashboard show
1698
+ // the IdP-pushed Users/Groups without exposing the token-gated /scim/v2 API
1699
+ // to a browser session. Read-only; never returns secrets.
1700
+ let provisioningStore = null;
1701
+ app.get("/api/provisioning", need("users", "delete"), (_req, res) => {
1702
+ res.json(projectProvisioning(provisioningStore));
1703
+ });
1695
1704
  // --- /api/subjects — aggregated principals catalogue ------------------
1696
1705
  // The third k8s-shaped RBAC view: who the deployment knows about.
1697
1706
  // Three independent sources, returned in three independent arrays so
@@ -2285,6 +2294,8 @@ async function main() {
2285
2294
  redis: scimRedis,
2286
2295
  redisKey: process.env.OMCP_SCIM_REDIS_KEY?.trim(),
2287
2296
  });
2297
+ // Expose the same store (read-only) to the dashboard's /api/provisioning.
2298
+ provisioningStore = scimStore;
2288
2299
  registerScimRoutes(app, {
2289
2300
  store: scimStore,
2290
2301
  bearerToken: scimToken,
package/dist/openapi.js CHANGED
@@ -659,6 +659,36 @@ export function buildOpenApiSpec(version) {
659
659
  },
660
660
  },
661
661
  },
662
+ "/api/provisioning": {
663
+ get: {
664
+ tags: ["auth"],
665
+ summary: "Read-only view of SCIM-provisioned Users/Groups (admin-only).",
666
+ description: "Mirrors the directory an identity provider has pushed via SCIM 2.0 (/scim/v2). Read-only and secret-free — never returns the SCIM bearer token. When SCIM is not enabled (no OMCP_SCIM_TOKEN), returns configured:false with an explanatory note rather than a 404.",
667
+ responses: {
668
+ "200": {
669
+ description: "Provisioning payload.",
670
+ content: { "application/json": { schema: {
671
+ type: "object",
672
+ properties: {
673
+ configured: { type: "boolean" },
674
+ users: { type: "array", items: { type: "object", properties: {
675
+ userName: { type: "string" }, displayName: { type: "string" },
676
+ active: { type: "boolean" },
677
+ groups: { type: "array", items: { type: "string" } },
678
+ externalId: { type: "string" },
679
+ } } },
680
+ groups: { type: "array", items: { type: "object", properties: {
681
+ displayName: { type: "string" }, members: { type: "integer" },
682
+ externalId: { type: "string" },
683
+ } } },
684
+ note: { type: "string" },
685
+ },
686
+ } } },
687
+ },
688
+ "403": { description: "Missing users:delete permission (admin-only)." },
689
+ },
690
+ },
691
+ },
662
692
  "/api/subjects": {
663
693
  get: {
664
694
  tags: ["auth"],
@@ -45,6 +45,13 @@ export interface PostmortemInput {
45
45
  traces: TraceSummary[];
46
46
  /** Optional log-error summary lines, e.g. ["payment-service: 412 5xx in window"]. */
47
47
  logHighlights?: string[];
48
+ /** Optional custom report template (issue: v3.3 candidate). When set, the
49
+ * markdown body is rendered by interpolating `{{placeholder}}` tokens
50
+ * instead of the built-in layout. Unset → the default report (unchanged).
51
+ * Available tokens: service, window, from, to, tenant, synopsis, timeline,
52
+ * blastRadius, signals, traces, logHighlights, followUps. An unknown token
53
+ * is left verbatim so a typo is visible rather than silently dropped. */
54
+ template?: string;
48
55
  }
49
56
  export interface PostmortemReport {
50
57
  service: string;
@@ -81,3 +88,20 @@ export interface PostmortemReport {
81
88
  /** Synthesise one report from already-fetched primitives. Pure
82
89
  * compute — no I/O. */
83
90
  export declare function synthesizePostmortem(input: PostmortemInput): PostmortemReport;
91
+ type RenderCtx = {
92
+ input: PostmortemInput;
93
+ timeline: PostmortemReport["sections"]["timeline"];
94
+ contributingSignals: PostmortemReport["sections"]["contributingSignals"];
95
+ peakNode: BlastRadiusNode | undefined;
96
+ peakScore: number;
97
+ errorTraces: number;
98
+ blastSize: number;
99
+ followUps: string[];
100
+ synopsis: string;
101
+ };
102
+ /** Build the `{{token}}` → markdown-block map for a custom template. */
103
+ export declare function buildTemplateVars(ctx: RenderCtx): Record<string, string>;
104
+ /** Interpolate `{{token}}` placeholders. An unknown token is left verbatim so
105
+ * a typo is visible in the output rather than silently producing a blank. */
106
+ export declare function renderTemplate(template: string, vars: Record<string, string>): string;
107
+ export {};
@@ -22,7 +22,7 @@ export function synthesizePostmortem(input) {
22
22
  const blastSize = input.blastRadius.nodes.length;
23
23
  const followUps = inferFollowUps(input, { peakScore, errorTraces, blastSize });
24
24
  const synopsis = synopsisFor(input, peakScore, errorTraces, blastSize);
25
- const markdown = renderMarkdown({
25
+ const renderCtx = {
26
26
  input,
27
27
  timeline,
28
28
  contributingSignals,
@@ -32,7 +32,12 @@ export function synthesizePostmortem(input) {
32
32
  blastSize,
33
33
  followUps,
34
34
  synopsis,
35
- });
35
+ };
36
+ // Default layout unless the operator supplied a custom template — the
37
+ // default path is byte-for-byte unchanged.
38
+ const markdown = input.template
39
+ ? renderTemplate(input.template, buildTemplateVars(renderCtx))
40
+ : renderMarkdown(renderCtx);
36
41
  return {
37
42
  service: input.service,
38
43
  window: input.window,
@@ -203,3 +208,51 @@ function renderMarkdown(ctx) {
203
208
  // could approach MB without the slice() caps above.
204
209
  return lines.join("\n");
205
210
  }
211
+ /** Build the `{{token}}` → markdown-block map for a custom template. */
212
+ export function buildTemplateVars(ctx) {
213
+ const { input, timeline, contributingSignals, peakNode, errorTraces, followUps, synopsis } = ctx;
214
+ const timelineBlock = timeline.length === 0
215
+ ? "_No anomaly samples in this window._"
216
+ : ["| ts | service | score | severity | method |", "|---|---|---|---|---|",
217
+ ...timeline.slice(0, 20).map((t) => `| \`${t.ts}\` | \`${t.service}\` | ${t.score} | ${t.severity} | ${t.method} |`),
218
+ ...(timeline.length > 20 ? [`| … | _${timeline.length - 20} more rows_ | | | |`] : [])].join("\n");
219
+ const brLines = [];
220
+ brLines.push(peakNode ? `Root node: **\`${peakNode.name}\`** (\`${peakNode.kind}\`).` : "_Topology snapshot empty._");
221
+ if (input.blastRadius.nodes.length > 0) {
222
+ brLines.push("", "| node | kind |", "|---|---|", ...input.blastRadius.nodes.slice(0, 30).map((n) => `| \`${n.name}\`${n.root ? " *(root)*" : ""} | \`${n.kind}\` |`));
223
+ }
224
+ brLines.push("", `Edges in radius: **${input.blastRadius.edges.length}**.`);
225
+ const blastRadiusBlock = brLines.join("\n");
226
+ const signalsBlock = contributingSignals.length === 0
227
+ ? "_No anomaly samples to rank._"
228
+ : ["| signal | samples | mean score |", "|---|---|---|",
229
+ ...contributingSignals.slice(0, 10).map((s) => `| \`${s.signal}\` | ${s.count} | ${s.meanScore} |`)].join("\n");
230
+ const tracesBlock = input.traces.length === 0
231
+ ? "_No traces returned for the window. Configure a Tempo / Jaeger source if traces are expected._"
232
+ : ["| trace | service | duration ms | error |", "|---|---|---|---|",
233
+ ...input.traces.slice(0, 10).map((t) => `| \`${t.traceId}\` | \`${t.rootService}\` | ${t.durationMs} | ${t.hasError ? "yes" : "no"} |`),
234
+ ...(errorTraces > 0 ? [`\n_${errorTraces} of the returned traces carried error spans._`] : [])].join("\n");
235
+ const logHighlightsBlock = (input.logHighlights ?? []).length === 0
236
+ ? "_No log highlights._"
237
+ : input.logHighlights.map((l) => `- ${l}`).join("\n");
238
+ const followUpsBlock = followUps.map((f) => `- ${f}`).join("\n");
239
+ return {
240
+ service: input.service,
241
+ window: input.window,
242
+ from: input.fromIso,
243
+ to: input.toIso,
244
+ tenant: input.tenant,
245
+ synopsis,
246
+ timeline: timelineBlock,
247
+ blastRadius: blastRadiusBlock,
248
+ signals: signalsBlock,
249
+ traces: tracesBlock,
250
+ logHighlights: logHighlightsBlock,
251
+ followUps: followUpsBlock,
252
+ };
253
+ }
254
+ /** Interpolate `{{token}}` placeholders. An unknown token is left verbatim so
255
+ * a typo is visible in the output rather than silently producing a blank. */
256
+ export function renderTemplate(template, vars) {
257
+ return template.replace(/\{\{\s*([a-zA-Z][\w]*)\s*\}\}/g, (whole, key) => Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : whole);
258
+ }
@@ -139,3 +139,30 @@ test("synthesizePostmortem: report carries the input window + iso bounds back in
139
139
  assert.equal(r.fromIso, "2026-06-06T00:00:00.000Z");
140
140
  assert.equal(r.toIso, "2026-06-06T01:00:00.000Z");
141
141
  });
142
+ test("custom template: tokens are interpolated; default path unchanged when no template", () => {
143
+ const data = input({
144
+ anomalies: [anomaly("2026-06-06T00:10:00Z", 0.7, "mad", "warn", "cpu")],
145
+ blastRadius: { nodes: [{ id: "n1", kind: "pod", name: "payment-1", root: true }], edges: [] },
146
+ logHighlights: ["payment: 5xx spike"],
147
+ });
148
+ // Default (no template) still produces the built-in report.
149
+ const def = synthesizePostmortem(data);
150
+ assert.match(def.markdown, /^# Post-mortem — payment/);
151
+ // Custom template interpolates the known tokens.
152
+ const tpl = "INCIDENT {{service}} ({{window}})\n\n{{synopsis}}\n\nTIMELINE:\n{{timeline}}\n\nFOLLOWUPS:\n{{followUps}}\n\nLOGS:\n{{logHighlights}}";
153
+ const out = synthesizePostmortem({ ...data, template: tpl }).markdown;
154
+ assert.match(out, /^INCIDENT payment \(1h\)/);
155
+ assert.ok(!out.includes("# Post-mortem"), "custom template replaces the default layout");
156
+ assert.match(out, /TIMELINE:\n\| ts \| service \| score/);
157
+ assert.match(out, /LOGS:\n- payment: 5xx spike/);
158
+ assert.ok(out.includes(def.synopsis), "synopsis token expands to the computed synopsis");
159
+ });
160
+ test("custom template: unknown token is left verbatim (visible typo, not silent blank)", () => {
161
+ const out = synthesizePostmortem({ ...input(), template: "{{service}} / {{nope}}" }).markdown;
162
+ assert.equal(out, "payment / {{nope}}");
163
+ });
164
+ test("custom template: empty sections render their placeholder text, not a crash", () => {
165
+ const out = synthesizePostmortem({ ...input(), template: "T:{{timeline}} L:{{logHighlights}}" }).markdown;
166
+ assert.match(out, /T:_No anomaly samples in this window\._/);
167
+ assert.match(out, /L:_No log highlights\._/);
168
+ });
@@ -0,0 +1,23 @@
1
+ import type { IScimStore } from "./store.js";
2
+ export interface ProvisioningUserView {
3
+ userName: string;
4
+ displayName: string;
5
+ active: boolean;
6
+ groups: string[];
7
+ externalId?: string;
8
+ }
9
+ export interface ProvisioningGroupView {
10
+ displayName: string;
11
+ members: number;
12
+ externalId?: string;
13
+ }
14
+ export interface ProvisioningView {
15
+ configured: boolean;
16
+ users: ProvisioningUserView[];
17
+ groups: ProvisioningGroupView[];
18
+ note?: string;
19
+ }
20
+ /** Project the SCIM store into the compact, secret-free shape the UI renders.
21
+ * A null store (SCIM not enabled) yields configured:false + an explanatory
22
+ * note, NOT an error — the dashboard shows "how to enable" instead of a 404. */
23
+ export declare function projectProvisioning(store: IScimStore | null): ProvisioningView;
@@ -0,0 +1,27 @@
1
+ // Read-only projection of the SCIM store for the dashboard's Provisioning
2
+ // sub-tab (/api/provisioning). Pure + secret-free: only the fields the UI
3
+ // table renders, never the SCIM bearer token or anything sensitive. Kept
4
+ // separate from the route handler so it's unit-testable without booting the app.
5
+ const NOT_CONFIGURED_NOTE = "SCIM provisioning is not enabled. Set OMCP_SCIM_TOKEN (and OMCP_SCIM_BACKEND/store) " +
6
+ "to let an identity provider push Users/Groups — this view then mirrors that directory.";
7
+ /** Project the SCIM store into the compact, secret-free shape the UI renders.
8
+ * A null store (SCIM not enabled) yields configured:false + an explanatory
9
+ * note, NOT an error — the dashboard shows "how to enable" instead of a 404. */
10
+ export function projectProvisioning(store) {
11
+ if (!store) {
12
+ return { configured: false, users: [], groups: [], note: NOT_CONFIGURED_NOTE };
13
+ }
14
+ const users = store.listUsers().map((u) => ({
15
+ userName: u.userName,
16
+ displayName: u.displayName || u.name?.formatted || "",
17
+ active: u.active !== false,
18
+ groups: (u.groups || []).map((g) => g.display || g.value),
19
+ externalId: u.externalId,
20
+ }));
21
+ const groups = store.listGroups().map((g) => ({
22
+ displayName: g.displayName,
23
+ members: (g.members || []).length,
24
+ externalId: g.externalId,
25
+ }));
26
+ return { configured: true, users, groups };
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { projectProvisioning } from "./provisioning-view.js";
4
+ const meta = { resourceType: "User", created: "", lastModified: "", location: "" };
5
+ function storeWith(users, groups) {
6
+ // Only listUsers/listGroups are exercised by the projection.
7
+ return { listUsers: () => users, listGroups: () => groups };
8
+ }
9
+ describe("projectProvisioning", () => {
10
+ it("returns configured:false + a note when the store is null (SCIM not enabled)", () => {
11
+ const v = projectProvisioning(null);
12
+ assert.equal(v.configured, false);
13
+ assert.deepEqual(v.users, []);
14
+ assert.deepEqual(v.groups, []);
15
+ assert.match(v.note ?? "", /OMCP_SCIM_TOKEN/);
16
+ });
17
+ it("projects users to a compact, secret-free shape", () => {
18
+ const store = storeWith([{
19
+ schemas: [], id: "u1", userName: "alice@x.com", active: true,
20
+ displayName: "Alice", groups: [{ value: "g1", display: "Admins" }],
21
+ externalId: "ext-1", meta: { ...meta },
22
+ }], []);
23
+ const v = projectProvisioning(store);
24
+ assert.equal(v.configured, true);
25
+ assert.deepEqual(v.users, [{
26
+ userName: "alice@x.com", displayName: "Alice", active: true,
27
+ groups: ["Admins"], externalId: "ext-1",
28
+ }]);
29
+ // No secret/raw fields leaked.
30
+ assert.ok(!("schemas" in v.users[0]) && !("meta" in v.users[0]));
31
+ });
32
+ it("active defaults to true when unset; displayName falls back to name.formatted", () => {
33
+ const store = storeWith([{ schemas: [], id: "u2", userName: "bob", name: { formatted: "Bob B" }, meta: { ...meta } }], []);
34
+ const v = projectProvisioning(store);
35
+ assert.equal(v.users[0].active, true);
36
+ assert.equal(v.users[0].displayName, "Bob B");
37
+ assert.deepEqual(v.users[0].groups, []);
38
+ });
39
+ it("active:false is preserved", () => {
40
+ const store = storeWith([{ schemas: [], id: "u3", userName: "carol", active: false, meta: { ...meta } }], []);
41
+ assert.equal(projectProvisioning(store).users[0].active, false);
42
+ });
43
+ it("projects groups with a member count, not the member list", () => {
44
+ const store = storeWith([], [{
45
+ schemas: [], id: "g1", displayName: "Admins",
46
+ members: [{ value: "u1" }, { value: "u2" }], externalId: "grp-1", meta: { ...meta },
47
+ }]);
48
+ const v = projectProvisioning(store);
49
+ assert.deepEqual(v.groups, [{ displayName: "Admins", members: 2, externalId: "grp-1" }]);
50
+ });
51
+ });
@@ -0,0 +1,32 @@
1
+ export interface ScimFilter {
2
+ attr: string;
3
+ op: "eq";
4
+ /** Comparison value: a string, or a boolean for `active eq true`. */
5
+ value: string | boolean;
6
+ }
7
+ /** Parse a SCIM `filter` expression. Returns null for an empty/absent filter,
8
+ * or throws {unsupported:true} for a syntactically-valid filter we don't
9
+ * implement (a non-eq operator) so the caller can answer 400, not 200. */
10
+ export declare function parseScimFilter(raw: string | undefined): ScimFilter | null;
11
+ export interface ScimListResult<T> {
12
+ resources: T[];
13
+ totalResults: number;
14
+ startIndex: number;
15
+ itemsPerPage: number;
16
+ }
17
+ /**
18
+ * Apply a SCIM `eq` filter + startIndex/count pagination to a resource list.
19
+ * `filter`/`startIndex`/`count` are the raw query-string values.
20
+ *
21
+ * - filter: only `eq` (string, case-insensitive; or boolean for `active`).
22
+ * - startIndex: 1-based (SCIM); clamped to >= 1; default 1.
23
+ * - count: page size; clamped to [0, 1000]; absent → all remaining.
24
+ *
25
+ * Throws a {scimUnsupported:true} error for a non-eq filter so the route can
26
+ * return 400 rather than a misleading full list.
27
+ */
28
+ export declare function applyScimList<T extends Record<string, unknown>>(all: T[], q: {
29
+ filter?: string;
30
+ startIndex?: string;
31
+ count?: string;
32
+ }): ScimListResult<T>;
@@ -0,0 +1,73 @@
1
+ // SCIM 2.0 list query: filter + pagination (RFC 7644 §3.4.2).
2
+ //
3
+ // Identity providers (Entra, Okta) reconcile by issuing
4
+ // GET /Users?filter=userName eq "alice@example.com"
5
+ // and page large directories with startIndex/count. Without filter support a
6
+ // provider has to pull the whole list and match client-side — slow and, for
7
+ // Okta, a hard requirement. We support the `eq` operator on top-level string
8
+ // attributes (userName, displayName, externalId, id) plus `active eq true`,
9
+ // which covers what Entra/Okta actually send. Other operators are reported as
10
+ // unsupported (400) rather than silently returning everything — silence here
11
+ // would make a provider think "no match" when it means "not implemented".
12
+ /** Parse a SCIM `filter` expression. Returns null for an empty/absent filter,
13
+ * or throws {unsupported:true} for a syntactically-valid filter we don't
14
+ * implement (a non-eq operator) so the caller can answer 400, not 200. */
15
+ export function parseScimFilter(raw) {
16
+ if (!raw || !raw.trim())
17
+ return null;
18
+ const s = raw.trim();
19
+ // <attr> eq "value" | <attr> eq true|false
20
+ const m = /^([A-Za-z][\w.]*)\s+(eq)\s+(?:"([^"]*)"|(true|false))$/i.exec(s);
21
+ if (!m) {
22
+ // Either an unsupported operator (co, sw, gt, …) or malformed. Signal
23
+ // unsupported so the route returns 400 instead of an all-rows 200.
24
+ const err = new Error(`Unsupported or malformed SCIM filter: ${raw}`);
25
+ err.scimUnsupported = true;
26
+ throw err;
27
+ }
28
+ const attr = m[1];
29
+ const value = m[3] !== undefined ? m[3] : m[4].toLowerCase() === "true";
30
+ return { attr, op: "eq", value };
31
+ }
32
+ /** Read a top-level attribute off a SCIM resource for `eq` comparison.
33
+ * Only flat attributes are supported (userName/displayName/externalId/id/
34
+ * active) — nested paths (name.familyName) aren't part of the eq surface. */
35
+ function attrValue(resource, attr) {
36
+ // Case-insensitive attribute match per the SCIM spec (attribute names are
37
+ // case-insensitive); providers send `userName`, some send `username`.
38
+ const key = Object.keys(resource).find((k) => k.toLowerCase() === attr.toLowerCase());
39
+ return key ? resource[key] : undefined;
40
+ }
41
+ /**
42
+ * Apply a SCIM `eq` filter + startIndex/count pagination to a resource list.
43
+ * `filter`/`startIndex`/`count` are the raw query-string values.
44
+ *
45
+ * - filter: only `eq` (string, case-insensitive; or boolean for `active`).
46
+ * - startIndex: 1-based (SCIM); clamped to >= 1; default 1.
47
+ * - count: page size; clamped to [0, 1000]; absent → all remaining.
48
+ *
49
+ * Throws a {scimUnsupported:true} error for a non-eq filter so the route can
50
+ * return 400 rather than a misleading full list.
51
+ */
52
+ export function applyScimList(all, q) {
53
+ const filter = parseScimFilter(q.filter);
54
+ let filtered = all;
55
+ if (filter) {
56
+ filtered = all.filter((r) => {
57
+ const v = attrValue(r, filter.attr);
58
+ if (typeof filter.value === "boolean")
59
+ return Boolean(v) === filter.value;
60
+ // String eq is case-insensitive per the SCIM spec for these attrs.
61
+ return v !== undefined && String(v).toLowerCase() === filter.value.toLowerCase();
62
+ });
63
+ }
64
+ const total = filtered.length;
65
+ const startIndex = Math.max(1, Number.parseInt(q.startIndex ?? "1", 10) || 1);
66
+ const from = startIndex - 1;
67
+ const rawCount = q.count === undefined ? undefined : Number.parseInt(q.count, 10);
68
+ const count = rawCount === undefined || Number.isNaN(rawCount)
69
+ ? undefined
70
+ : Math.min(1000, Math.max(0, rawCount));
71
+ const page = count === undefined ? filtered.slice(from) : filtered.slice(from, from + count);
72
+ return { resources: page, totalResults: total, startIndex, itemsPerPage: page.length };
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseScimFilter, applyScimList } from "./query.js";
4
+ describe("parseScimFilter", () => {
5
+ it("returns null for absent/empty filter", () => {
6
+ assert.equal(parseScimFilter(undefined), null);
7
+ assert.equal(parseScimFilter(" "), null);
8
+ });
9
+ it("parses string eq (case-insensitive op)", () => {
10
+ assert.deepEqual(parseScimFilter('userName eq "alice@x.com"'), { attr: "userName", op: "eq", value: "alice@x.com" });
11
+ assert.deepEqual(parseScimFilter('displayName EQ "Admins"'), { attr: "displayName", op: "eq", value: "Admins" });
12
+ });
13
+ it("parses boolean eq for active", () => {
14
+ assert.deepEqual(parseScimFilter("active eq true"), { attr: "active", op: "eq", value: true });
15
+ assert.deepEqual(parseScimFilter("active eq false"), { attr: "active", op: "eq", value: false });
16
+ });
17
+ it("throws scimUnsupported for non-eq operators / malformed", () => {
18
+ for (const f of ['userName co "a"', 'userName sw "a"', 'userName pr', 'garbage']) {
19
+ assert.throws(() => parseScimFilter(f), (e) => e.scimUnsupported === true, `expected unsupported for: ${f}`);
20
+ }
21
+ });
22
+ });
23
+ describe("applyScimList", () => {
24
+ const users = [
25
+ { id: "1", userName: "alice@x.com", active: true },
26
+ { id: "2", userName: "bob@x.com", active: false },
27
+ { id: "3", userName: "carol@x.com", active: true },
28
+ ];
29
+ it("no filter, no pagination → all rows, correct envelope", () => {
30
+ const r = applyScimList(users, {});
31
+ assert.equal(r.totalResults, 3);
32
+ assert.equal(r.startIndex, 1);
33
+ assert.equal(r.itemsPerPage, 3);
34
+ assert.equal(r.resources.length, 3);
35
+ });
36
+ it("eq filter matches case-insensitively on the value", () => {
37
+ const r = applyScimList(users, { filter: 'userName eq "ALICE@X.COM"' });
38
+ assert.equal(r.totalResults, 1);
39
+ assert.equal(r.resources[0].id, "1");
40
+ });
41
+ it("eq filter with no match → empty, totalResults 0 (not all rows)", () => {
42
+ const r = applyScimList(users, { filter: 'userName eq "nobody@x.com"' });
43
+ assert.equal(r.totalResults, 0);
44
+ assert.equal(r.resources.length, 0);
45
+ });
46
+ it("boolean eq filters on active", () => {
47
+ assert.equal(applyScimList(users, { filter: "active eq true" }).totalResults, 2);
48
+ assert.equal(applyScimList(users, { filter: "active eq false" }).totalResults, 1);
49
+ });
50
+ it("pagination: startIndex + count slice; totalResults is the pre-page count", () => {
51
+ const r = applyScimList(users, { startIndex: "2", count: "1" });
52
+ assert.equal(r.totalResults, 3);
53
+ assert.equal(r.startIndex, 2);
54
+ assert.equal(r.itemsPerPage, 1);
55
+ assert.equal(r.resources[0].id, "2");
56
+ });
57
+ it("count=0 returns an empty page but the real totalResults", () => {
58
+ const r = applyScimList(users, { count: "0" });
59
+ assert.equal(r.totalResults, 3);
60
+ assert.equal(r.resources.length, 0);
61
+ });
62
+ it("filter + pagination compose", () => {
63
+ const r = applyScimList(users, { filter: "active eq true", startIndex: "2", count: "5" });
64
+ assert.equal(r.totalResults, 2); // alice + carol
65
+ assert.equal(r.resources.length, 1); // from index 2 of the 2 matches
66
+ assert.equal(r.resources[0].id, "3");
67
+ });
68
+ it("propagates the unsupported-filter error for the route to 400", () => {
69
+ assert.throws(() => applyScimList(users, { filter: 'userName co "x"' }), (e) => e.scimUnsupported === true);
70
+ });
71
+ });
@@ -4,7 +4,7 @@
4
4
  // GET /scim/v2/ServiceProviderConfig
5
5
  // GET /scim/v2/ResourceTypes
6
6
  // GET /scim/v2/Schemas
7
- // GET /scim/v2/Users list (no filter support yet)
7
+ // GET /scim/v2/Users list (filter: <attr> eq "x"; startIndex/count)
8
8
  // GET /scim/v2/Users/:id
9
9
  // POST /scim/v2/Users
10
10
  // PATCH /scim/v2/Users/:id minimal: replace top-level attrs
@@ -20,6 +20,7 @@
20
20
  import { timingSafeEqual } from "node:crypto";
21
21
  import { SCIM_SCHEMA_LIST_RESPONSE, SCIM_SCHEMA_USER, SCIM_SCHEMA_GROUP, scimError, } from "./types.js";
22
22
  import { ScimNotFoundError, ScimValidationError } from "./store.js";
23
+ import { applyScimList } from "./query.js";
23
24
  const constantTimeBearerMatch = (raw, expected) => {
24
25
  if (!raw)
25
26
  return false;
@@ -57,7 +58,7 @@ export function registerScimRoutes(app, deps) {
57
58
  documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
58
59
  patch: { supported: true },
59
60
  bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
60
- filter: { supported: false, maxResults: 200 },
61
+ filter: { supported: true, maxResults: 200 },
61
62
  changePassword: { supported: false },
62
63
  sort: { supported: false },
63
64
  etag: { supported: false },
@@ -108,14 +109,25 @@ export function registerScimRoutes(app, deps) {
108
109
  });
109
110
  });
110
111
  // ---- Users ----
111
- app.get("/scim/v2/Users", (_req, res) => {
112
+ app.get("/scim/v2/Users", (req, res) => {
112
113
  const users = store.listUsers().map((u) => withGroups(u, store));
114
+ let page;
115
+ try {
116
+ page = applyScimList(users, req.query);
117
+ }
118
+ catch (e) {
119
+ if (e.scimUnsupported) {
120
+ res.status(400).json(scimError(400, e.message));
121
+ return;
122
+ }
123
+ throw e;
124
+ }
113
125
  res.json({
114
126
  schemas: [SCIM_SCHEMA_LIST_RESPONSE],
115
- totalResults: users.length,
116
- itemsPerPage: users.length,
117
- startIndex: 1,
118
- Resources: users,
127
+ totalResults: page.totalResults,
128
+ itemsPerPage: page.itemsPerPage,
129
+ startIndex: page.startIndex,
130
+ Resources: page.resources,
119
131
  });
120
132
  });
121
133
  app.get("/scim/v2/Users/:id", (req, res) => {
@@ -157,14 +169,25 @@ export function registerScimRoutes(app, deps) {
157
169
  res.status(204).end();
158
170
  });
159
171
  // ---- Groups ----
160
- app.get("/scim/v2/Groups", (_req, res) => {
172
+ app.get("/scim/v2/Groups", (req, res) => {
161
173
  const groups = store.listGroups();
174
+ let page;
175
+ try {
176
+ page = applyScimList(groups, req.query);
177
+ }
178
+ catch (e) {
179
+ if (e.scimUnsupported) {
180
+ res.status(400).json(scimError(400, e.message));
181
+ return;
182
+ }
183
+ throw e;
184
+ }
162
185
  res.json({
163
186
  schemas: [SCIM_SCHEMA_LIST_RESPONSE],
164
- totalResults: groups.length,
165
- itemsPerPage: groups.length,
166
- startIndex: 1,
167
- Resources: groups,
187
+ totalResults: page.totalResults,
188
+ itemsPerPage: page.itemsPerPage,
189
+ startIndex: page.startIndex,
190
+ Resources: page.resources,
168
191
  });
169
192
  });
170
193
  app.get("/scim/v2/Groups/:id", (req, res) => {
@@ -1,5 +1,7 @@
1
1
  import type { ConnectorRegistry } from "../connectors/registry.js";
2
2
  import { type RequestContext } from "../context.js";
3
+ /** Test seam: reset the cached template (so tests can vary the env). */
4
+ export declare function _resetPostmortemTemplateCache(): void;
3
5
  export declare const generatePostmortemDefinition: {
4
6
  name: "generate_postmortem";
5
7
  description: string;
@@ -7,9 +7,37 @@
7
7
  // The synthesizer is pure compute (see ./../postmortem/synthesizer);
8
8
  // this handler is just the orchestration: pull each upstream
9
9
  // primitive in parallel, hand the result to the synthesizer.
10
+ import { readFileSync } from "node:fs";
10
11
  import { defaultContext } from "../context.js";
11
12
  import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
12
13
  import { synthesizePostmortem, } from "../postmortem/synthesizer.js";
14
+ // Optional operator-supplied report template (v3.3 candidate). Set
15
+ // OMCP_POSTMORTEM_TEMPLATE to a file of `{{token}}` placeholders to override
16
+ // the built-in layout; unset → the default report. Read once and cached; a
17
+ // read error falls back to the default (logged once) rather than failing the
18
+ // tool — a broken template must not break incident reporting.
19
+ let _templateCache;
20
+ function loadPostmortemTemplate(env = process.env) {
21
+ if (_templateCache !== undefined)
22
+ return _templateCache ?? undefined;
23
+ const path = env.OMCP_POSTMORTEM_TEMPLATE?.trim();
24
+ if (!path) {
25
+ _templateCache = null;
26
+ return undefined;
27
+ }
28
+ try {
29
+ _templateCache = readFileSync(path, "utf8");
30
+ }
31
+ catch (err) {
32
+ console.error("OMCP_POSTMORTEM_TEMPLATE unreadable, using the built-in layout:", err instanceof Error ? err.message : err);
33
+ _templateCache = null;
34
+ }
35
+ return _templateCache ?? undefined;
36
+ }
37
+ /** Test seam: reset the cached template (so tests can vary the env). */
38
+ export function _resetPostmortemTemplateCache() {
39
+ _templateCache = undefined;
40
+ }
13
41
  export const generatePostmortemDefinition = {
14
42
  name: "generate_postmortem",
15
43
  description: [
@@ -59,6 +87,7 @@ export async function generatePostmortemHandler(registry, args, ctx = defaultCon
59
87
  blastRadius,
60
88
  traces,
61
89
  logHighlights,
90
+ template: loadPostmortemTemplate(),
62
91
  });
63
92
  // Which primitives actually carried data. A post-mortem synthesised from
64
93
  // ZERO signal still renders a full, authoritative-looking document — an
@@ -1,7 +1,10 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
3
6
  import { ConnectorRegistry } from "../connectors/registry.js";
4
- import { generatePostmortemHandler } from "./generate-postmortem.js";
7
+ import { generatePostmortemHandler, _resetPostmortemTemplateCache } from "./generate-postmortem.js";
5
8
  // R5 — a post-mortem built from ZERO signal (no anomaly/traces/topology/log
6
9
  // backend) must label itself, not render as an authoritative finding.
7
10
  describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
@@ -20,3 +23,50 @@ describe("generatePostmortemHandler — no-signal honesty (R5)", () => {
20
23
  assert.deepEqual(report.coverage, { anomalies: false, traces: false, topology: false, logs: false });
21
24
  });
22
25
  });
26
+ describe("generatePostmortemHandler — custom template via OMCP_POSTMORTEM_TEMPLATE (C3)", () => {
27
+ it("renders the operator's template when the env file is set; falls back when unset", async () => {
28
+ const dir = mkdtempSync(join(tmpdir(), "pm-tpl-"));
29
+ const file = join(dir, "tpl.md");
30
+ writeFileSync(file, "## Incident: {{service}} over {{window}}\n\n{{synopsis}}");
31
+ const prev = process.env.OMCP_POSTMORTEM_TEMPLATE;
32
+ try {
33
+ process.env.OMCP_POSTMORTEM_TEMPLATE = file;
34
+ _resetPostmortemTemplateCache();
35
+ const reg = new ConnectorRegistry();
36
+ const md = (await generatePostmortemHandler(reg, { service: "payment", duration: "2h" })).content[0].text;
37
+ // The custom template body is used (the no-signal honesty banner may
38
+ // still prepend it — that's deliberate and template-independent).
39
+ assert.match(md, /## Incident: payment over 2h/);
40
+ assert.ok(!md.includes("# Post-mortem —"), "custom template replaces the default layout");
41
+ // Unset → back to the built-in layout.
42
+ delete process.env.OMCP_POSTMORTEM_TEMPLATE;
43
+ _resetPostmortemTemplateCache();
44
+ const md2 = (await generatePostmortemHandler(reg, { service: "payment", duration: "2h" })).content[0].text;
45
+ assert.match(md2, /# Post-mortem — payment/);
46
+ }
47
+ finally {
48
+ if (prev === undefined)
49
+ delete process.env.OMCP_POSTMORTEM_TEMPLATE;
50
+ else
51
+ process.env.OMCP_POSTMORTEM_TEMPLATE = prev;
52
+ _resetPostmortemTemplateCache();
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+ it("an unreadable template path falls back to the default layout, doesn't throw", async () => {
57
+ const prev = process.env.OMCP_POSTMORTEM_TEMPLATE;
58
+ try {
59
+ process.env.OMCP_POSTMORTEM_TEMPLATE = "/nonexistent/nope.md";
60
+ _resetPostmortemTemplateCache();
61
+ const md = (await generatePostmortemHandler(new ConnectorRegistry(), { service: "x", duration: "1h" })).content[0].text;
62
+ assert.match(md, /# Post-mortem — x/);
63
+ }
64
+ finally {
65
+ if (prev === undefined)
66
+ delete process.env.OMCP_POSTMORTEM_TEMPLATE;
67
+ else
68
+ process.env.OMCP_POSTMORTEM_TEMPLATE = prev;
69
+ _resetPostmortemTemplateCache();
70
+ }
71
+ });
72
+ });
@@ -2226,6 +2226,7 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
2226
2226
  <button class="pol-subtab" role="tab" aria-controls="pol-pane-roles" data-pol-tab="roles" onclick="polSetTab('roles')">Roles</button>
2227
2227
  <button class="pol-subtab" role="tab" aria-controls="pol-pane-bindings" data-pol-tab="bindings" onclick="polSetTab('bindings')">Bindings</button>
2228
2228
  <button class="pol-subtab" role="tab" aria-controls="pol-pane-subjects" data-pol-tab="subjects" onclick="polSetTab('subjects')">Subjects</button>
2229
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-provisioning" data-pol-tab="provisioning" onclick="polSetTab('provisioning')">Provisioning</button>
2229
2230
  <button class="pol-subtab" role="tab" aria-controls="pol-pane-batch" data-pol-tab="batch" onclick="polSetTab('batch')">Batch evaluate</button>
2230
2231
  </nav>
2231
2232
 
@@ -2326,6 +2327,21 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
2326
2327
  <div id="pol-subjects-body" class="content"><div class="empty">Loading…</div></div>
2327
2328
  </div>
2328
2329
 
2330
+ <!-- Provisioning sub-tab (C2b) — read-only view of the SCIM-provisioned
2331
+ Users/Groups an identity provider has pushed via /scim/v2. -->
2332
+ <div class="card pol-pane" id="pol-pane-provisioning" role="tabpanel" hidden>
2333
+ <div class="card-header"><h2>Provisioning
2334
+ <button class="info" aria-label="About provisioning"
2335
+ data-title="SCIM provisioning"
2336
+ data-info="Read-only view of the Users and Groups an identity provider (Entra, Okta) has pushed via SCIM 2.0 at /scim/v2. Enable by setting OMCP_SCIM_TOKEN. This dashboard never exposes the SCIM bearer token; the IdP reconciles directly against the /scim/v2 endpoints."
2337
+ onclick="infoPop(this)">?</button>
2338
+ <span style="flex:1"></span>
2339
+ <input id="pol-provisioning-filter" type="search" class="input" placeholder="Filter…"
2340
+ style="max-width:220px" oninput="polRenderProvisioning()" aria-label="Filter provisioned identities">
2341
+ </div>
2342
+ <div id="pol-provisioning-body" class="content"><div class="empty">Loading…</div></div>
2343
+ </div>
2344
+
2329
2345
  <!-- Batch evaluate sub-tab (P4) — wraps POST /api/policy/dry-run-batch
2330
2346
  into a UI: subjects × resources × actions multi-select,
2331
2347
  one click renders a green/red heat-map matrix the user
@@ -3148,10 +3164,76 @@ function polSetTab(name) {
3148
3164
  // Lazy-load Subjects on first visit — it has its own endpoint
3149
3165
  // so deferring the fetch keeps the page-enter cost low.
3150
3166
  if (name === 'subjects') polLoadSubjects();
3167
+ if (name === 'provisioning') polLoadProvisioning();
3151
3168
  if (name === 'bindings') polLoadBindings();
3152
3169
  if (name === 'batch') polBatchInit();
3153
3170
  }
3154
3171
 
3172
+ // --- Provisioning sub-tab (C2b) — read-only SCIM Users/Groups view ---
3173
+ let POL_PROVISIONING = null;
3174
+ async function polLoadProvisioning() {
3175
+ const body = document.getElementById('pol-provisioning-body');
3176
+ if (!body) return;
3177
+ if (POL_PROVISIONING) { polRenderProvisioning(); return; }
3178
+ try {
3179
+ const r = await fetch('/api/provisioning');
3180
+ if (!r.ok) {
3181
+ body.innerHTML = '<div class="empty">Provisioning view requires the <code>users:delete</code> permission (admin role).</div>';
3182
+ return;
3183
+ }
3184
+ POL_PROVISIONING = await r.json();
3185
+ polRenderProvisioning();
3186
+ } catch (e) {
3187
+ body.innerHTML = '<div class="empty">Provisioning unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3188
+ }
3189
+ }
3190
+ function polRenderProvisioning() {
3191
+ const body = document.getElementById('pol-provisioning-body');
3192
+ const j = POL_PROVISIONING;
3193
+ if (!body || !j) return;
3194
+ if (!j.configured) {
3195
+ body.innerHTML = '<div class="pol-subjects-empty" style="padding:var(--sp-4)">'
3196
+ + escHtml(j.note || 'SCIM provisioning is not enabled.') + '</div>';
3197
+ return;
3198
+ }
3199
+ const q = (document.getElementById('pol-provisioning-filter')?.value || '').toLowerCase();
3200
+ const match = (s) => !q || String(s).toLowerCase().includes(q);
3201
+ const users = (j.users || []).filter((u) => match(u.userName) || match(u.displayName) || (u.groups || []).some(match));
3202
+ const groups = (j.groups || []).filter((g) => match(g.displayName));
3203
+
3204
+ const usersHtml = users.map((u) => `
3205
+ <tr>
3206
+ <td><code>${escHtml(u.userName)}</code></td>
3207
+ <td>${escHtml(u.displayName || '') || '<span class="t-sm" style="opacity:.5">—</span>'}</td>
3208
+ <td>${u.active ? '<span class="pill">active</span>' : '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">inactive</span>'}</td>
3209
+ <td>${(u.groups || []).map((g) => `<span class="pill">${escHtml(g)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>'}</td>
3210
+ </tr>`).join('');
3211
+ const groupsHtml = groups.map((g) => `
3212
+ <tr>
3213
+ <td><code>${escHtml(g.displayName)}</code></td>
3214
+ <td>${g.members}</td>
3215
+ </tr>`).join('');
3216
+
3217
+ const usersTbl = usersHtml
3218
+ ? `<table class="data-table" style="width:100%"><thead><tr><th>Username</th><th>Display name</th><th>Status</th><th>Groups</th></tr></thead><tbody>${usersHtml}</tbody></table>`
3219
+ : `<div class="pol-subjects-empty">${q ? 'No users match the filter.' : 'No provisioned users yet — the identity provider has not pushed any.'}</div>`;
3220
+ const groupsTbl = groupsHtml
3221
+ ? `<table class="data-table" style="width:100%"><thead><tr><th>Group</th><th>Members</th></tr></thead><tbody>${groupsHtml}</tbody></table>`
3222
+ : `<div class="pol-subjects-empty">${q ? 'No groups match the filter.' : 'No provisioned groups yet.'}</div>`;
3223
+
3224
+ body.innerHTML = `
3225
+ <div class="pol-subjects-section">
3226
+ <h3>Users <span class="pol-subjects-count">${(j.users || []).length}</span>
3227
+ <span class="pol-subjects-source">via SCIM /scim/v2/Users</span></h3>
3228
+ ${usersTbl}
3229
+ </div>
3230
+ <div class="pol-subjects-section">
3231
+ <h3>Groups <span class="pol-subjects-count">${(j.groups || []).length}</span>
3232
+ <span class="pol-subjects-source">via SCIM /scim/v2/Groups</span></h3>
3233
+ ${groupsTbl}
3234
+ </div>`;
3235
+ }
3236
+
3155
3237
  // --- Batch evaluate sub-tab (P4) ---
3156
3238
  //
3157
3239
  // One round-trip to POST /api/policy/dry-run-batch with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.4.0",
3
+ "version": "3.6.0",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",