@thotischner/observability-mcp 3.5.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"],
@@ -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
+ });
@@ -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.5.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",