@thotischner/observability-mcp 3.0.0 → 3.0.1
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/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/auth/policy/batch-dry-run.js +15 -0
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +306 -65
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/ui/index.html +856 -101
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Redis-backed SCIM store — same surface as the file-backed ScimStore,
|
|
2
|
+
// persists the snapshot in a single Redis key.
|
|
3
|
+
//
|
|
4
|
+
// Multi-replica deployments need a shared store so that a Microsoft
|
|
5
|
+
// Entra / Okta SCIM-push delivered to replica A is visible to replica
|
|
6
|
+
// B's reads. The file store can't do that without a shared filesystem;
|
|
7
|
+
// this one targets the same Redis the F8 SessionStore + the F8b
|
|
8
|
+
// transport-map ride on (Q11 promotes the transport-map onto the same
|
|
9
|
+
// interface).
|
|
10
|
+
//
|
|
11
|
+
// Concurrency note. SCIM clients (Entra, Okta, JumpCloud, generic
|
|
12
|
+
// SCIM) deliver provisioning requests SERIALLY per resource — the
|
|
13
|
+
// upstream IDP holds the connection open until the gateway responds.
|
|
14
|
+
// A single load-balanced gateway in front of N replicas observes one
|
|
15
|
+
// in-flight request per resource at a time, so last-writer-wins on
|
|
16
|
+
// the single-key snapshot matches the source-of-truth semantics of
|
|
17
|
+
// SCIM provisioning. We still serialise persists within a replica
|
|
18
|
+
// via a small mutex so concurrent route handlers don't lose writes
|
|
19
|
+
// to each other in the read-modify-write window.
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { nowIso, SCIM_SCHEMA_GROUP, SCIM_SCHEMA_USER, } from "./types.js";
|
|
22
|
+
import { ScimNotFoundError, ScimValidationError } from "./store.js";
|
|
23
|
+
const DEFAULT_KEY = "omcp:scim:snapshot";
|
|
24
|
+
export class RedisScimStore {
|
|
25
|
+
redis;
|
|
26
|
+
key;
|
|
27
|
+
snapshot = { users: [], groups: [] };
|
|
28
|
+
bootstrapped = null;
|
|
29
|
+
writeQueue = Promise.resolve();
|
|
30
|
+
constructor(redis, opts = {}) {
|
|
31
|
+
this.redis = redis;
|
|
32
|
+
this.key = opts.key || DEFAULT_KEY;
|
|
33
|
+
}
|
|
34
|
+
async load() {
|
|
35
|
+
if (this.bootstrapped)
|
|
36
|
+
return this.bootstrapped;
|
|
37
|
+
this.bootstrapped = (async () => {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await this.redis.get(this.key);
|
|
40
|
+
if (!raw) {
|
|
41
|
+
this.snapshot = { users: [], groups: [] };
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
this.snapshot = {
|
|
46
|
+
users: Array.isArray(parsed.users) ? parsed.users : [],
|
|
47
|
+
groups: Array.isArray(parsed.groups) ? parsed.groups : [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.warn(`[scim] failed to load redis snapshot ${this.key}: ${err.message} — starting empty`);
|
|
52
|
+
this.snapshot = { users: [], groups: [] };
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return this.bootstrapped;
|
|
56
|
+
}
|
|
57
|
+
listUsers() {
|
|
58
|
+
return this.snapshot.users.slice();
|
|
59
|
+
}
|
|
60
|
+
getUser(id) {
|
|
61
|
+
return this.snapshot.users.find((u) => u.id === id);
|
|
62
|
+
}
|
|
63
|
+
getUserByUserName(userName) {
|
|
64
|
+
return this.snapshot.users.find((u) => u.userName === userName);
|
|
65
|
+
}
|
|
66
|
+
async createUser(input) {
|
|
67
|
+
if (!input.userName)
|
|
68
|
+
throw new ScimValidationError("userName is required");
|
|
69
|
+
if (this.getUserByUserName(input.userName)) {
|
|
70
|
+
throw new ScimValidationError(`User with userName '${input.userName}' already exists`, "uniqueness");
|
|
71
|
+
}
|
|
72
|
+
const ts = nowIso();
|
|
73
|
+
const user = {
|
|
74
|
+
schemas: [SCIM_SCHEMA_USER],
|
|
75
|
+
id: randomUUID(),
|
|
76
|
+
userName: input.userName,
|
|
77
|
+
active: input.active ?? true,
|
|
78
|
+
displayName: input.displayName,
|
|
79
|
+
name: input.name,
|
|
80
|
+
emails: input.emails,
|
|
81
|
+
externalId: input.externalId,
|
|
82
|
+
meta: { resourceType: "User", created: ts, lastModified: ts },
|
|
83
|
+
};
|
|
84
|
+
this.snapshot.users.push(user);
|
|
85
|
+
await this.persist();
|
|
86
|
+
return user;
|
|
87
|
+
}
|
|
88
|
+
async updateUser(id, patch) {
|
|
89
|
+
const i = this.snapshot.users.findIndex((u) => u.id === id);
|
|
90
|
+
if (i < 0)
|
|
91
|
+
throw new ScimNotFoundError(`User ${id} not found`);
|
|
92
|
+
const next = {
|
|
93
|
+
...this.snapshot.users[i],
|
|
94
|
+
...patch,
|
|
95
|
+
schemas: [SCIM_SCHEMA_USER],
|
|
96
|
+
id,
|
|
97
|
+
meta: {
|
|
98
|
+
...this.snapshot.users[i].meta,
|
|
99
|
+
lastModified: nowIso(),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
this.snapshot.users[i] = next;
|
|
103
|
+
await this.persist();
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
async deleteUser(id) {
|
|
107
|
+
const before = this.snapshot.users.length;
|
|
108
|
+
this.snapshot.users = this.snapshot.users.filter((u) => u.id !== id);
|
|
109
|
+
if (this.snapshot.users.length === before)
|
|
110
|
+
return false;
|
|
111
|
+
for (const g of this.snapshot.groups) {
|
|
112
|
+
g.members = (g.members ?? []).filter((m) => m.value !== id);
|
|
113
|
+
}
|
|
114
|
+
await this.persist();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
listGroups() {
|
|
118
|
+
return this.snapshot.groups.slice();
|
|
119
|
+
}
|
|
120
|
+
getGroup(id) {
|
|
121
|
+
return this.snapshot.groups.find((g) => g.id === id);
|
|
122
|
+
}
|
|
123
|
+
async createGroup(input) {
|
|
124
|
+
if (!input.displayName)
|
|
125
|
+
throw new ScimValidationError("displayName is required");
|
|
126
|
+
const ts = nowIso();
|
|
127
|
+
const group = {
|
|
128
|
+
schemas: [SCIM_SCHEMA_GROUP],
|
|
129
|
+
id: randomUUID(),
|
|
130
|
+
displayName: input.displayName,
|
|
131
|
+
members: input.members ?? [],
|
|
132
|
+
externalId: input.externalId,
|
|
133
|
+
meta: { resourceType: "Group", created: ts, lastModified: ts },
|
|
134
|
+
};
|
|
135
|
+
this.snapshot.groups.push(group);
|
|
136
|
+
await this.persist();
|
|
137
|
+
return group;
|
|
138
|
+
}
|
|
139
|
+
async updateGroup(id, patch) {
|
|
140
|
+
const i = this.snapshot.groups.findIndex((g) => g.id === id);
|
|
141
|
+
if (i < 0)
|
|
142
|
+
throw new ScimNotFoundError(`Group ${id} not found`);
|
|
143
|
+
const next = {
|
|
144
|
+
...this.snapshot.groups[i],
|
|
145
|
+
...patch,
|
|
146
|
+
schemas: [SCIM_SCHEMA_GROUP],
|
|
147
|
+
id,
|
|
148
|
+
meta: {
|
|
149
|
+
...this.snapshot.groups[i].meta,
|
|
150
|
+
lastModified: nowIso(),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
this.snapshot.groups[i] = next;
|
|
154
|
+
await this.persist();
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
async deleteGroup(id) {
|
|
158
|
+
const before = this.snapshot.groups.length;
|
|
159
|
+
this.snapshot.groups = this.snapshot.groups.filter((g) => g.id !== id);
|
|
160
|
+
if (this.snapshot.groups.length === before)
|
|
161
|
+
return false;
|
|
162
|
+
await this.persist();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
groupsContaining(userId) {
|
|
166
|
+
return this.snapshot.groups
|
|
167
|
+
.filter((g) => (g.members ?? []).some((m) => m.value === userId))
|
|
168
|
+
.map((g) => ({ value: g.id, display: g.displayName }));
|
|
169
|
+
}
|
|
170
|
+
persist() {
|
|
171
|
+
// Serialise persists so two concurrent updateUser calls don't
|
|
172
|
+
// race each other to the SET — the snapshot in memory is the
|
|
173
|
+
// canonical state, Redis just mirrors it.
|
|
174
|
+
const snap = { users: this.snapshot.users.slice(), groups: this.snapshot.groups.slice() };
|
|
175
|
+
this.writeQueue = this.writeQueue.then(() => this.redis.set(this.key, JSON.stringify(snap)).then(() => undefined));
|
|
176
|
+
return this.writeQueue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { RedisScimStore } from "./redis-store.js";
|
|
4
|
+
import { ScimNotFoundError, ScimValidationError } from "./store.js";
|
|
5
|
+
/** In-memory fake of the RedisLike surface — single-key GET/SET. */
|
|
6
|
+
function fakeRedis(initial) {
|
|
7
|
+
const store = new Map(Object.entries(initial || {}));
|
|
8
|
+
return {
|
|
9
|
+
_store: store,
|
|
10
|
+
_writeCount: 0,
|
|
11
|
+
async get(key) { return store.has(key) ? store.get(key) : null; },
|
|
12
|
+
async set(key, value) {
|
|
13
|
+
store.set(key, value);
|
|
14
|
+
this._writeCount += 1;
|
|
15
|
+
return "OK";
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
test("load() initialises empty when redis key is missing", async () => {
|
|
20
|
+
const r = fakeRedis();
|
|
21
|
+
const s = new RedisScimStore(r);
|
|
22
|
+
await s.load();
|
|
23
|
+
assert.deepEqual(s.listUsers(), []);
|
|
24
|
+
assert.deepEqual(s.listGroups(), []);
|
|
25
|
+
});
|
|
26
|
+
test("load() hydrates from a serialised snapshot in redis", async () => {
|
|
27
|
+
const r = fakeRedis({
|
|
28
|
+
"omcp:scim:snapshot": JSON.stringify({
|
|
29
|
+
users: [{ id: "u1", userName: "alice@example.com", schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], meta: { resourceType: "User" } }],
|
|
30
|
+
groups: [],
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const s = new RedisScimStore(r);
|
|
34
|
+
await s.load();
|
|
35
|
+
assert.equal(s.listUsers().length, 1);
|
|
36
|
+
assert.equal(s.listUsers()[0].userName, "alice@example.com");
|
|
37
|
+
});
|
|
38
|
+
test("createUser persists to redis", async () => {
|
|
39
|
+
const r = fakeRedis();
|
|
40
|
+
const s = new RedisScimStore(r);
|
|
41
|
+
await s.load();
|
|
42
|
+
const u = await s.createUser({ userName: "bob@example.com" });
|
|
43
|
+
assert.ok(u.id);
|
|
44
|
+
assert.equal(u.userName, "bob@example.com");
|
|
45
|
+
// Persisted snapshot round-trips through redis
|
|
46
|
+
const raw = JSON.parse(r._store.get("omcp:scim:snapshot"));
|
|
47
|
+
assert.equal(raw.users.length, 1);
|
|
48
|
+
assert.equal(raw.users[0].userName, "bob@example.com");
|
|
49
|
+
});
|
|
50
|
+
test("createUser enforces unique userName", async () => {
|
|
51
|
+
const r = fakeRedis();
|
|
52
|
+
const s = new RedisScimStore(r);
|
|
53
|
+
await s.load();
|
|
54
|
+
await s.createUser({ userName: "alice@example.com" });
|
|
55
|
+
await assert.rejects(s.createUser({ userName: "alice@example.com" }), ScimValidationError);
|
|
56
|
+
});
|
|
57
|
+
test("createUser without userName throws", async () => {
|
|
58
|
+
const r = fakeRedis();
|
|
59
|
+
const s = new RedisScimStore(r);
|
|
60
|
+
await s.load();
|
|
61
|
+
await assert.rejects(s.createUser({}), ScimValidationError);
|
|
62
|
+
});
|
|
63
|
+
test("updateUser preserves id + sets lastModified to a not-earlier ts", async () => {
|
|
64
|
+
const r = fakeRedis();
|
|
65
|
+
const s = new RedisScimStore(r);
|
|
66
|
+
await s.load();
|
|
67
|
+
const u = await s.createUser({ userName: "u@x" });
|
|
68
|
+
// 2ms gap so the ISO timestamp differs even on slow CI clocks
|
|
69
|
+
await new Promise((res) => setTimeout(res, 2));
|
|
70
|
+
const u2 = await s.updateUser(u.id, { displayName: "U" });
|
|
71
|
+
assert.equal(u2.id, u.id);
|
|
72
|
+
assert.equal(u2.displayName, "U");
|
|
73
|
+
assert.ok(u2.meta.lastModified >= u.meta.lastModified);
|
|
74
|
+
});
|
|
75
|
+
test("updateUser missing throws ScimNotFoundError", async () => {
|
|
76
|
+
const r = fakeRedis();
|
|
77
|
+
const s = new RedisScimStore(r);
|
|
78
|
+
await s.load();
|
|
79
|
+
await assert.rejects(s.updateUser("nope", { displayName: "x" }), ScimNotFoundError);
|
|
80
|
+
});
|
|
81
|
+
test("deleteUser purges the user from all group member lists", async () => {
|
|
82
|
+
const r = fakeRedis();
|
|
83
|
+
const s = new RedisScimStore(r);
|
|
84
|
+
await s.load();
|
|
85
|
+
const u = await s.createUser({ userName: "u@x" });
|
|
86
|
+
const g = await s.createGroup({ displayName: "admins", members: [{ value: u.id, display: "u@x" }] });
|
|
87
|
+
await s.deleteUser(u.id);
|
|
88
|
+
const updated = s.getGroup(g.id);
|
|
89
|
+
assert.equal(updated?.members?.length, 0);
|
|
90
|
+
});
|
|
91
|
+
test("group CRUD round-trip persists", async () => {
|
|
92
|
+
const r = fakeRedis();
|
|
93
|
+
const s = new RedisScimStore(r);
|
|
94
|
+
await s.load();
|
|
95
|
+
const g = await s.createGroup({ displayName: "team-alpha" });
|
|
96
|
+
assert.equal(g.displayName, "team-alpha");
|
|
97
|
+
const g2 = await s.updateGroup(g.id, { displayName: "team-beta" });
|
|
98
|
+
assert.equal(g2.displayName, "team-beta");
|
|
99
|
+
assert.equal((await s.deleteGroup(g.id)), true);
|
|
100
|
+
assert.equal((await s.deleteGroup(g.id)), false);
|
|
101
|
+
});
|
|
102
|
+
test("groupsContaining returns membership", async () => {
|
|
103
|
+
const r = fakeRedis();
|
|
104
|
+
const s = new RedisScimStore(r);
|
|
105
|
+
await s.load();
|
|
106
|
+
const u = await s.createUser({ userName: "u@x" });
|
|
107
|
+
const g = await s.createGroup({ displayName: "admins", members: [{ value: u.id, display: "u@x" }] });
|
|
108
|
+
const groups = s.groupsContaining(u.id);
|
|
109
|
+
assert.equal(groups.length, 1);
|
|
110
|
+
assert.equal(groups[0].value, g.id);
|
|
111
|
+
assert.equal(groups[0].display, "admins");
|
|
112
|
+
});
|
|
113
|
+
test("custom key is honoured", async () => {
|
|
114
|
+
const r = fakeRedis();
|
|
115
|
+
const s = new RedisScimStore(r, { key: "custom:key" });
|
|
116
|
+
await s.load();
|
|
117
|
+
await s.createUser({ userName: "u@x" });
|
|
118
|
+
assert.ok(r._store.has("custom:key"));
|
|
119
|
+
assert.equal(r._store.has("omcp:scim:snapshot"), false);
|
|
120
|
+
});
|
|
121
|
+
test("concurrent writes serialise — no lost-write race", async () => {
|
|
122
|
+
const r = fakeRedis();
|
|
123
|
+
const s = new RedisScimStore(r);
|
|
124
|
+
await s.load();
|
|
125
|
+
const u1 = s.createUser({ userName: "a@x" });
|
|
126
|
+
const u2 = s.createUser({ userName: "b@x" });
|
|
127
|
+
const u3 = s.createUser({ userName: "c@x" });
|
|
128
|
+
await Promise.all([u1, u2, u3]);
|
|
129
|
+
const final = JSON.parse(r._store.get("omcp:scim:snapshot"));
|
|
130
|
+
assert.equal(final.users.length, 3);
|
|
131
|
+
});
|
|
132
|
+
test("malformed snapshot in redis → starts empty (warning logged)", async () => {
|
|
133
|
+
const r = fakeRedis({ "omcp:scim:snapshot": "this is not json" });
|
|
134
|
+
const s = new RedisScimStore(r);
|
|
135
|
+
await s.load();
|
|
136
|
+
assert.deepEqual(s.listUsers(), []);
|
|
137
|
+
assert.deepEqual(s.listGroups(), []);
|
|
138
|
+
});
|
package/dist/scim/routes.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Application } from "express";
|
|
2
|
-
import {
|
|
2
|
+
import { type ScimPatchRequest } from "./types.js";
|
|
3
|
+
import { type IScimStore } from "./store.js";
|
|
3
4
|
export interface ScimRoutesDeps {
|
|
4
|
-
store:
|
|
5
|
+
store: IScimStore;
|
|
5
6
|
bearerToken: string;
|
|
6
7
|
/** Audit hook called after every successful mutation. Best-effort. */
|
|
7
8
|
audit?: (event: {
|
|
@@ -13,3 +14,27 @@ export interface ScimRoutesDeps {
|
|
|
13
14
|
}) => void;
|
|
14
15
|
}
|
|
15
16
|
export declare function registerScimRoutes(app: Application, deps: ScimRoutesDeps): void;
|
|
17
|
+
/** Translate a SCIM PatchOp request into a partial resource patch.
|
|
18
|
+
*
|
|
19
|
+
* Supported (RFC 7644 §3.5.2):
|
|
20
|
+
* - replace, no path → whole-resource merge of allow-listed keys
|
|
21
|
+
* - replace, path=attr → set that attribute
|
|
22
|
+
* - add, no path → merge; array attrs append (deduped), scalars set
|
|
23
|
+
* - add, path=arrayAttr → append value(s) to the array (deduped)
|
|
24
|
+
* - add, path=scalarAttr → set
|
|
25
|
+
* - remove, path=arrayAttr → clear the array
|
|
26
|
+
* - remove, path=arrayAttr[sub eq "x"] → drop matching elements
|
|
27
|
+
* - remove, path=scalarAttr → clear the attribute (set undefined)
|
|
28
|
+
*
|
|
29
|
+
* Array ops are computed against the CURRENT resource value and the
|
|
30
|
+
* full resulting array is returned in `out` (the store merges
|
|
31
|
+
* shallowly, so out[attr] replaces current[attr] wholesale).
|
|
32
|
+
*
|
|
33
|
+
* Every property name written into `out` is gated through
|
|
34
|
+
* `PATCH_ALLOWED_ATTRS` so a SCIM client can't inject __proto__ /
|
|
35
|
+
* constructor / arbitrary fields (CodeQL js/remote-property-injection +
|
|
36
|
+
* js/prototype-polluting-assignment). Filter sub-attributes are
|
|
37
|
+
* read-only. */
|
|
38
|
+
export declare function applyPatchOps<T extends {
|
|
39
|
+
id: string;
|
|
40
|
+
}>(current: T | undefined, patch: ScimPatchRequest): Partial<T>;
|
package/dist/scim/routes.js
CHANGED
|
@@ -23,7 +23,11 @@ import { ScimNotFoundError, ScimValidationError } from "./store.js";
|
|
|
23
23
|
const constantTimeBearerMatch = (raw, expected) => {
|
|
24
24
|
if (!raw)
|
|
25
25
|
return false;
|
|
26
|
-
|
|
26
|
+
// Use bounded whitespace ({1,16}) instead of `\s+` to avoid the
|
|
27
|
+
// polynomial-ReDoS class — `\s+` followed by `(.+)` backtracks on
|
|
28
|
+
// strings like "bearer<many spaces><payload>". 16 is generous;
|
|
29
|
+
// RFC 7235 only requires one space.
|
|
30
|
+
const m = raw.match(/^Bearer[ \t]{1,16}(.+)$/i);
|
|
27
31
|
if (!m)
|
|
28
32
|
return false;
|
|
29
33
|
const a = Buffer.from(m[1].trim());
|
|
@@ -205,29 +209,171 @@ export function registerScimRoutes(app, deps) {
|
|
|
205
209
|
function withGroups(u, store) {
|
|
206
210
|
return { ...u, groups: store.groupsContaining(u.id) };
|
|
207
211
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
// Allow-list of attribute names patchable via SCIM PatchOp.
|
|
213
|
+
// `applyPatchOps` writes into a plain object using a user-supplied
|
|
214
|
+
// path/key — every key is gated through this set so a malicious
|
|
215
|
+
// client can't inject __proto__ / constructor / arbitrary fields.
|
|
216
|
+
const PATCH_ALLOWED_ATTRS = new Set([
|
|
217
|
+
"userName", "active", "displayName", "name", "emails", "externalId",
|
|
218
|
+
"members", "groups",
|
|
219
|
+
]);
|
|
220
|
+
function safePatchKey(k) {
|
|
221
|
+
return typeof k === "string" && PATCH_ALLOWED_ATTRS.has(k);
|
|
222
|
+
}
|
|
223
|
+
// Multi-valued attributes that support add/remove element ops + filter
|
|
224
|
+
// segments. Everything else is treated as a scalar (replace/set).
|
|
225
|
+
const PATCH_ARRAY_ATTRS = new Set(["members", "emails", "groups"]);
|
|
226
|
+
/** Parse a PatchOp `path` into {attr, filter?}. Supports a bare
|
|
227
|
+
* attribute (`members`) and a single `eq` filter segment
|
|
228
|
+
* (`members[value eq "abc"]`). Returns null for anything that
|
|
229
|
+
* doesn't name an allow-listed attribute or that we don't model —
|
|
230
|
+
* the caller skips those ops (fail-closed). The sub-attribute in the
|
|
231
|
+
* filter is only ever READ for comparison, never written, so it
|
|
232
|
+
* can't drive prototype pollution. */
|
|
233
|
+
function parsePatchPath(path) {
|
|
234
|
+
const filterMatch = /^([A-Za-z][\w]*)\[\s*([A-Za-z][\w]*)\s+eq\s+"([^"]*)"\s*\]$/.exec(path);
|
|
235
|
+
if (filterMatch) {
|
|
236
|
+
const attr = filterMatch[1];
|
|
237
|
+
if (!safePatchKey(attr))
|
|
238
|
+
return null;
|
|
239
|
+
return { attr, filter: { sub: filterMatch[2], val: filterMatch[3] } };
|
|
240
|
+
}
|
|
241
|
+
if (safePatchKey(path))
|
|
242
|
+
return { attr: path };
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
// Always returns a FRESH array — never the caller's reference — so the
|
|
246
|
+
// add/remove machinery can push/filter without mutating the current
|
|
247
|
+
// resource in place (which would corrupt it across chained ops or
|
|
248
|
+
// repeated calls).
|
|
249
|
+
function asArray(v) {
|
|
250
|
+
if (Array.isArray(v))
|
|
251
|
+
return v.slice();
|
|
252
|
+
if (v == null)
|
|
253
|
+
return [];
|
|
254
|
+
return [v];
|
|
255
|
+
}
|
|
256
|
+
/** Element identity for dedup/removal on multi-valued attrs. SCIM
|
|
257
|
+
* members/emails carry a `value` key; fall back to JSON equality. */
|
|
258
|
+
function elemKey(e) {
|
|
259
|
+
if (e && typeof e === "object" && "value" in e) {
|
|
260
|
+
return String(e.value);
|
|
261
|
+
}
|
|
262
|
+
return JSON.stringify(e);
|
|
263
|
+
}
|
|
264
|
+
/** Translate a SCIM PatchOp request into a partial resource patch.
|
|
265
|
+
*
|
|
266
|
+
* Supported (RFC 7644 §3.5.2):
|
|
267
|
+
* - replace, no path → whole-resource merge of allow-listed keys
|
|
268
|
+
* - replace, path=attr → set that attribute
|
|
269
|
+
* - add, no path → merge; array attrs append (deduped), scalars set
|
|
270
|
+
* - add, path=arrayAttr → append value(s) to the array (deduped)
|
|
271
|
+
* - add, path=scalarAttr → set
|
|
272
|
+
* - remove, path=arrayAttr → clear the array
|
|
273
|
+
* - remove, path=arrayAttr[sub eq "x"] → drop matching elements
|
|
274
|
+
* - remove, path=scalarAttr → clear the attribute (set undefined)
|
|
275
|
+
*
|
|
276
|
+
* Array ops are computed against the CURRENT resource value and the
|
|
277
|
+
* full resulting array is returned in `out` (the store merges
|
|
278
|
+
* shallowly, so out[attr] replaces current[attr] wholesale).
|
|
279
|
+
*
|
|
280
|
+
* Every property name written into `out` is gated through
|
|
281
|
+
* `PATCH_ALLOWED_ATTRS` so a SCIM client can't inject __proto__ /
|
|
282
|
+
* constructor / arbitrary fields (CodeQL js/remote-property-injection +
|
|
283
|
+
* js/prototype-polluting-assignment). Filter sub-attributes are
|
|
284
|
+
* read-only. */
|
|
285
|
+
export function applyPatchOps(current, patch) {
|
|
215
286
|
if (!current)
|
|
216
287
|
throw new ScimNotFoundError("target resource not found");
|
|
217
288
|
if (!patch?.Operations || !Array.isArray(patch.Operations))
|
|
218
289
|
return {};
|
|
290
|
+
const cur = current;
|
|
219
291
|
const out = {};
|
|
292
|
+
// Read the working value for an attr: prefer an in-progress value
|
|
293
|
+
// from a prior op in this same request, else the current resource.
|
|
294
|
+
const readAttr = (attr) => (attr in out ? out[attr] : cur[attr]);
|
|
220
295
|
for (const op of patch.Operations) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
296
|
+
const verb = (op.op || "").toLowerCase();
|
|
297
|
+
if (verb === "replace") {
|
|
298
|
+
if (!op.path) {
|
|
299
|
+
if (op.value && typeof op.value === "object") {
|
|
300
|
+
for (const [k, v] of Object.entries(op.value)) {
|
|
301
|
+
if (safePatchKey(k))
|
|
302
|
+
out[k] = v;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const parsed = parsePatchPath(op.path);
|
|
308
|
+
if (parsed && !parsed.filter)
|
|
309
|
+
out[parsed.attr] = op.value;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (verb === "add") {
|
|
313
|
+
if (!op.path) {
|
|
314
|
+
if (op.value && typeof op.value === "object") {
|
|
315
|
+
for (const [k, v] of Object.entries(op.value)) {
|
|
316
|
+
if (!safePatchKey(k))
|
|
317
|
+
continue;
|
|
318
|
+
if (PATCH_ARRAY_ATTRS.has(k)) {
|
|
319
|
+
const existing = asArray(readAttr(k));
|
|
320
|
+
const seen = new Set(existing.map(elemKey));
|
|
321
|
+
for (const item of asArray(v)) {
|
|
322
|
+
if (!seen.has(elemKey(item))) {
|
|
323
|
+
existing.push(item);
|
|
324
|
+
seen.add(elemKey(item));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
out[k] = existing;
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
out[k] = v;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const parsed = parsePatchPath(op.path);
|
|
337
|
+
if (!parsed || parsed.filter)
|
|
338
|
+
continue;
|
|
339
|
+
if (PATCH_ARRAY_ATTRS.has(parsed.attr)) {
|
|
340
|
+
const existing = asArray(readAttr(parsed.attr));
|
|
341
|
+
const seen = new Set(existing.map(elemKey));
|
|
342
|
+
for (const item of asArray(op.value)) {
|
|
343
|
+
if (!seen.has(elemKey(item))) {
|
|
344
|
+
existing.push(item);
|
|
345
|
+
seen.add(elemKey(item));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
out[parsed.attr] = existing;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
out[parsed.attr] = op.value;
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (verb === "remove") {
|
|
356
|
+
if (!op.path)
|
|
357
|
+
continue; // remove with no path is a no-op (nothing to target)
|
|
358
|
+
const parsed = parsePatchPath(op.path);
|
|
359
|
+
if (!parsed)
|
|
360
|
+
continue;
|
|
361
|
+
if (parsed.filter && PATCH_ARRAY_ATTRS.has(parsed.attr)) {
|
|
362
|
+
const existing = asArray(readAttr(parsed.attr));
|
|
363
|
+
const { sub, val } = parsed.filter;
|
|
364
|
+
out[parsed.attr] = existing.filter((e) => {
|
|
365
|
+
const ev = e && typeof e === "object" ? e[sub] : undefined;
|
|
366
|
+
return String(ev) !== val;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
else if (PATCH_ARRAY_ATTRS.has(parsed.attr)) {
|
|
370
|
+
out[parsed.attr] = [];
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
out[parsed.attr] = undefined;
|
|
227
374
|
}
|
|
228
375
|
continue;
|
|
229
376
|
}
|
|
230
|
-
out[op.path] = op.value;
|
|
231
377
|
}
|
|
232
378
|
return out;
|
|
233
379
|
}
|
package/dist/scim/store.d.ts
CHANGED
|
@@ -3,7 +3,30 @@ export interface ScimSnapshot {
|
|
|
3
3
|
users: ScimUser[];
|
|
4
4
|
groups: ScimGroup[];
|
|
5
5
|
}
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The store surface route handlers and group-role-map depend on.
|
|
8
|
+
* Both file + redis backends implement this. New backends (DynamoDB,
|
|
9
|
+
* Postgres, …) just need to satisfy these methods.
|
|
10
|
+
*/
|
|
11
|
+
export interface IScimStore {
|
|
12
|
+
load(): Promise<void>;
|
|
13
|
+
listUsers(): ScimUser[];
|
|
14
|
+
getUser(id: string): ScimUser | undefined;
|
|
15
|
+
getUserByUserName(userName: string): ScimUser | undefined;
|
|
16
|
+
createUser(input: Partial<ScimUser>): Promise<ScimUser>;
|
|
17
|
+
updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
|
|
18
|
+
deleteUser(id: string): Promise<boolean>;
|
|
19
|
+
listGroups(): ScimGroup[];
|
|
20
|
+
getGroup(id: string): ScimGroup | undefined;
|
|
21
|
+
createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
22
|
+
updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
23
|
+
deleteGroup(id: string): Promise<boolean>;
|
|
24
|
+
groupsContaining(userId: string): Array<{
|
|
25
|
+
value: string;
|
|
26
|
+
display?: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
export declare class ScimStore implements IScimStore {
|
|
7
30
|
private readonly path;
|
|
8
31
|
private snapshot;
|
|
9
32
|
private bootstrapped;
|
|
@@ -35,3 +58,19 @@ export declare class ScimValidationError extends Error {
|
|
|
35
58
|
export declare class ScimNotFoundError extends Error {
|
|
36
59
|
constructor(message: string);
|
|
37
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Boot-time factory. Reads OMCP_SCIM_BACKEND and returns the matching
|
|
63
|
+
* implementation. Loadbearing on Helm too — values.yaml exposes a
|
|
64
|
+
* scim.backend toggle that ends up as the env var.
|
|
65
|
+
*
|
|
66
|
+
* config.path — file backend storage path (file backend only).
|
|
67
|
+
* config.redis — RedisLike client (redis backend only).
|
|
68
|
+
* config.redisKey — override default key name.
|
|
69
|
+
*/
|
|
70
|
+
export interface CreateScimStoreConfig {
|
|
71
|
+
backend?: "file" | "redis";
|
|
72
|
+
path?: string;
|
|
73
|
+
redis?: import("./redis-store.js").RedisLike;
|
|
74
|
+
redisKey?: string;
|
|
75
|
+
}
|
|
76
|
+
export declare function createScimStore(config?: CreateScimStoreConfig): Promise<IScimStore>;
|
package/dist/scim/store.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// SCIM store — file-backed JSON for users + groups
|
|
1
|
+
// SCIM store — file-backed JSON for users + groups (default), with a
|
|
2
|
+
// Redis-backed alternative for multi-replica deployments behind the
|
|
3
|
+
// `OMCP_SCIM_BACKEND=redis` env / `scim.backend: redis` Helm value.
|
|
2
4
|
//
|
|
3
|
-
// F21a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// F21a shipped the on-disk JSON file (atomic tmp+rename, mode 0600).
|
|
6
|
+
// F21b (Q6) adds the Redis variant + the IScimStore interface every
|
|
7
|
+
// route handler now talks to. The factory `createScimStore` picks
|
|
8
|
+
// the implementation at boot; everything downstream is unchanged.
|
|
7
9
|
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
8
10
|
import { dirname } from "node:path";
|
|
9
11
|
import { randomUUID } from "node:crypto";
|
|
@@ -176,3 +178,19 @@ export class ScimNotFoundError extends Error {
|
|
|
176
178
|
this.name = "ScimNotFoundError";
|
|
177
179
|
}
|
|
178
180
|
}
|
|
181
|
+
export async function createScimStore(config = {}) {
|
|
182
|
+
const backend = (config.backend || process.env.OMCP_SCIM_BACKEND || "file");
|
|
183
|
+
if (backend === "redis") {
|
|
184
|
+
if (!config.redis) {
|
|
185
|
+
throw new Error("createScimStore: backend=redis requires a redis client. Pass config.redis (RedisLike).");
|
|
186
|
+
}
|
|
187
|
+
const { RedisScimStore } = await import("./redis-store.js");
|
|
188
|
+
const store = new RedisScimStore(config.redis, { key: config.redisKey });
|
|
189
|
+
await store.load();
|
|
190
|
+
return store;
|
|
191
|
+
}
|
|
192
|
+
const path = config.path || process.env.OMCP_SCIM_STORE || "/var/lib/observability-mcp/scim.json";
|
|
193
|
+
const store = new ScimStore(path);
|
|
194
|
+
await store.load();
|
|
195
|
+
return store;
|
|
196
|
+
}
|