@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.
Files changed (53) hide show
  1. package/dist/audit/sinks/s3.d.ts +61 -0
  2. package/dist/audit/sinks/s3.js +179 -0
  3. package/dist/audit/sinks/s3.test.d.ts +1 -0
  4. package/dist/audit/sinks/s3.test.js +175 -0
  5. package/dist/auth/policy/batch-dry-run.js +15 -0
  6. package/dist/connectors/loader.d.ts +8 -0
  7. package/dist/connectors/loader.js +49 -0
  8. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  9. package/dist/connectors/manifest-hooks.test.js +206 -0
  10. package/dist/federation/registry.d.ts +27 -5
  11. package/dist/federation/registry.js +49 -4
  12. package/dist/federation/registry.test.js +79 -3
  13. package/dist/federation/upstream.d.ts +32 -6
  14. package/dist/federation/upstream.js +60 -12
  15. package/dist/federation/upstream.test.d.ts +1 -0
  16. package/dist/federation/upstream.test.js +118 -0
  17. package/dist/index.js +306 -65
  18. package/dist/metrics/self.d.ts +1 -0
  19. package/dist/metrics/self.js +8 -0
  20. package/dist/policy/redact.js +1 -1
  21. package/dist/postmortem/store.d.ts +34 -0
  22. package/dist/postmortem/store.js +113 -0
  23. package/dist/postmortem/store.test.d.ts +1 -0
  24. package/dist/postmortem/store.test.js +118 -0
  25. package/dist/scim/compliance.test.d.ts +1 -0
  26. package/dist/scim/compliance.test.js +169 -0
  27. package/dist/scim/factory.test.d.ts +1 -0
  28. package/dist/scim/factory.test.js +54 -0
  29. package/dist/scim/patch-ops.test.d.ts +1 -0
  30. package/dist/scim/patch-ops.test.js +100 -0
  31. package/dist/scim/redis-store.d.ts +38 -0
  32. package/dist/scim/redis-store.js +178 -0
  33. package/dist/scim/redis-store.test.d.ts +1 -0
  34. package/dist/scim/redis-store.test.js +138 -0
  35. package/dist/scim/routes.d.ts +27 -2
  36. package/dist/scim/routes.js +161 -15
  37. package/dist/scim/store.d.ts +40 -1
  38. package/dist/scim/store.js +23 -5
  39. package/dist/sdk/hook-wrappers.d.ts +39 -0
  40. package/dist/sdk/hook-wrappers.js +113 -0
  41. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  42. package/dist/sdk/hook-wrappers.test.js +204 -0
  43. package/dist/sdk/index.d.ts +13 -0
  44. package/dist/tools/detect-anomalies.d.ts +12 -1
  45. package/dist/tools/detect-anomalies.js +22 -2
  46. package/dist/tools/topology.js +23 -5
  47. package/dist/tools/topology.test.js +45 -0
  48. package/dist/transport/transportSessionMap.d.ts +70 -0
  49. package/dist/transport/transportSessionMap.js +128 -0
  50. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  51. package/dist/transport/transportSessionMap.test.js +111 -0
  52. package/dist/ui/index.html +856 -101
  53. 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
+ });
@@ -1,7 +1,8 @@
1
1
  import type { Application } from "express";
2
- import { ScimStore } from "./store.js";
2
+ import { type ScimPatchRequest } from "./types.js";
3
+ import { type IScimStore } from "./store.js";
3
4
  export interface ScimRoutesDeps {
4
- store: ScimStore;
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>;
@@ -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
- const m = raw.match(/^Bearer\s+(.+)$/i);
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
- /** Translate a SCIM PatchOp into a partial resource patch. Minimal:
209
- * we accept `op: "replace"` with no path (whole-resource merge) or
210
- * with a single-segment path naming a top-level attribute. `add` and
211
- * `remove` for members/emails arrays are a follow-up the
212
- * Entra/Okta provisioning checklists exercise replace-only on the
213
- * attributes we expose. */
214
- function applyPatchOps(current, patch) {
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
- if (op.op !== "replace")
222
- continue; // skip add/remove for F21a
223
- if (!op.path) {
224
- // value is a partial object — merge top-level keys
225
- if (op.value && typeof op.value === "object") {
226
- Object.assign(out, op.value);
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
  }
@@ -3,7 +3,30 @@ export interface ScimSnapshot {
3
3
  users: ScimUser[];
4
4
  groups: ScimGroup[];
5
5
  }
6
- export declare class ScimStore {
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>;
@@ -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 uses an on-disk JSON file (atomic tmp+rename, mode 0600).
4
- // Multi-replica deployments should plug the F8 SessionStore in here
5
- // that's F21b. The interface intentionally mirrors what the
6
- // SessionStore exposes so the swap is purely additive.
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
+ }