@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 +11 -0
- package/dist/openapi.js +30 -0
- package/dist/scim/provisioning-view.d.ts +23 -0
- package/dist/scim/provisioning-view.js +27 -0
- package/dist/scim/provisioning-view.test.d.ts +1 -0
- package/dist/scim/provisioning-view.test.js +51 -0
- package/dist/ui/index.html +82 -0
- package/package.json +1 -1
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
|
+
});
|
package/dist/ui/index.html
CHANGED
|
@@ -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