@thotischner/observability-mcp 1.8.1 → 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 (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -0,0 +1,33 @@
1
+ // Translate SCIM-provisioned groups into the gateway's RBAC roles.
2
+ // Operators configure the mapping via OMCP_SCIM_GROUP_ROLE_MAP:
3
+ //
4
+ // OMCP_SCIM_GROUP_ROLE_MAP="admins:admin,sre:operator,readers:viewer"
5
+ //
6
+ // A SCIM-managed user's groups[] (populated from group membership
7
+ // in the ScimStore) translates to a set of RBAC roles via this map,
8
+ // joining the OIDC group-mapping pattern from F6 so a federated
9
+ // IdP rolling Users + Groups via SCIM ends up with the same RBAC
10
+ // posture as a directly-claim-mapped login.
11
+ export function parseScimGroupRoleMap(raw) {
12
+ const out = new Map();
13
+ if (!raw)
14
+ return out;
15
+ for (const pair of raw.split(",")) {
16
+ const [groupName, role] = pair.split(":").map((s) => s.trim());
17
+ if (!groupName || !role)
18
+ continue;
19
+ out.set(groupName.toLowerCase(), role);
20
+ }
21
+ return out;
22
+ }
23
+ /** Map a user's group-display-names to the gateway's RBAC roles.
24
+ * Unknown groups are silently dropped (least-privilege). */
25
+ export function rolesForGroups(groupDisplayNames, map) {
26
+ const out = new Set();
27
+ for (const g of groupDisplayNames) {
28
+ const role = map.get(g.toLowerCase());
29
+ if (role)
30
+ out.add(role);
31
+ }
32
+ return [...out];
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseScimGroupRoleMap, rolesForGroups } from "./group-role-map.js";
4
+ test("parseScimGroupRoleMap: empty / undefined → empty map", () => {
5
+ assert.equal(parseScimGroupRoleMap(undefined).size, 0);
6
+ assert.equal(parseScimGroupRoleMap("").size, 0);
7
+ });
8
+ test("parseScimGroupRoleMap: comma-separated key:role pairs, lowercased keys", () => {
9
+ const m = parseScimGroupRoleMap("Admins:admin,SRE:operator,Readers:viewer");
10
+ assert.equal(m.get("admins"), "admin");
11
+ assert.equal(m.get("sre"), "operator");
12
+ assert.equal(m.get("readers"), "viewer");
13
+ });
14
+ test("parseScimGroupRoleMap: malformed entries silently dropped", () => {
15
+ const m = parseScimGroupRoleMap("admins:admin,no-colon,:emptyKey,validKey:validRole");
16
+ assert.equal(m.get("admins"), "admin");
17
+ assert.equal(m.get("validkey"), "validRole");
18
+ assert.equal(m.size, 2);
19
+ });
20
+ test("rolesForGroups: unknown groups dropped (least-privilege)", () => {
21
+ const map = parseScimGroupRoleMap("admins:admin,sre:operator");
22
+ const roles = rolesForGroups(["admins", "unknown-group"], map);
23
+ assert.deepEqual(roles, ["admin"]);
24
+ });
25
+ test("rolesForGroups: dedupes roles", () => {
26
+ const map = parseScimGroupRoleMap("admins:admin,sysadmins:admin");
27
+ const roles = rolesForGroups(["admins", "sysadmins"], map);
28
+ assert.deepEqual(roles, ["admin"]);
29
+ });
30
+ test("rolesForGroups: case-insensitive group lookup", () => {
31
+ const map = parseScimGroupRoleMap("Admins:admin");
32
+ assert.deepEqual(rolesForGroups(["ADMINS"], map), ["admin"]);
33
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyPatchOps } from "./routes.js";
4
+ function group(members = []) {
5
+ return {
6
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
7
+ id: "g1",
8
+ displayName: "team",
9
+ members,
10
+ meta: { resourceType: "Group", created: "2026-06-06T00:00:00Z", lastModified: "2026-06-06T00:00:00Z" },
11
+ };
12
+ }
13
+ function patch(...ops) {
14
+ return { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: ops };
15
+ }
16
+ // --- replace (existing behaviour preserved) --------------------------
17
+ test("replace with no path merges allow-listed keys", () => {
18
+ const out = applyPatchOps(group(), patch({ op: "replace", value: { displayName: "renamed", externalId: "x" } }));
19
+ assert.equal(out.displayName, "renamed");
20
+ assert.equal(out.externalId, "x");
21
+ });
22
+ test("replace with path sets that attribute", () => {
23
+ const out = applyPatchOps(group(), patch({ op: "replace", path: "displayName", value: "n2" }));
24
+ assert.equal(out.displayName, "n2");
25
+ });
26
+ test("replace drops non-allowlisted keys (proto-pollution guard)", () => {
27
+ const out = applyPatchOps(group(), patch({ op: "replace", value: { __proto__: { polluted: true }, constructor: 1, displayName: "ok" } }));
28
+ assert.equal(out.displayName, "ok");
29
+ assert.equal({}.polluted, undefined);
30
+ assert.equal(Object.prototype.hasOwnProperty.call(out, "__proto__"), false);
31
+ assert.equal(Object.prototype.hasOwnProperty.call(out, "constructor"), false);
32
+ });
33
+ // --- add members -----------------------------------------------------
34
+ test("add appends a member to an empty group", () => {
35
+ const out = applyPatchOps(group(), patch({ op: "add", path: "members", value: [{ value: "u1", display: "Alice" }] }));
36
+ assert.deepEqual(out.members, [{ value: "u1", display: "Alice" }]);
37
+ });
38
+ test("add appends to existing members and dedups by value", () => {
39
+ const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: [{ value: "u1" }, { value: "u2" }] }));
40
+ assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
41
+ });
42
+ test("add accepts a single (non-array) value", () => {
43
+ const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: { value: "u2" } }));
44
+ assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
45
+ });
46
+ test("pathless add appends array attrs + sets scalars", () => {
47
+ const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", value: { members: [{ value: "u2" }], displayName: "renamed" } }));
48
+ assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
49
+ assert.equal(out.displayName, "renamed");
50
+ });
51
+ // --- remove members --------------------------------------------------
52
+ test("remove with a filter drops the matching member", () => {
53
+ const out = applyPatchOps(group([{ value: "u1" }, { value: "u2" }, { value: "u3" }]), patch({ op: "remove", path: 'members[value eq "u2"]' }));
54
+ assert.deepEqual(out.members.map((m) => m.value), ["u1", "u3"]);
55
+ });
56
+ test("remove with a non-matching filter is a no-op on contents", () => {
57
+ const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "remove", path: 'members[value eq "ghost"]' }));
58
+ assert.deepEqual(out.members.map((m) => m.value), ["u1"]);
59
+ });
60
+ test("remove of the whole members attr clears it", () => {
61
+ const out = applyPatchOps(group([{ value: "u1" }, { value: "u2" }]), patch({ op: "remove", path: "members" }));
62
+ assert.deepEqual(out.members, []);
63
+ });
64
+ // --- chained ops in one request (Entra-style) ------------------------
65
+ test("add then remove in one request compose against the working value", () => {
66
+ const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: [{ value: "u2" }, { value: "u3" }] }, { op: "remove", path: 'members[value eq "u1"]' }));
67
+ assert.deepEqual(out.members.map((m) => m.value), ["u2", "u3"]);
68
+ });
69
+ // --- security: filtered paths can't pollute --------------------------
70
+ test("crafted __proto__ filter path is rejected (fail-closed no-op), no pollution", () => {
71
+ const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "remove", path: 'members[__proto__ eq "x"]' }));
72
+ // The sub-attribute regex requires a leading letter, so this path
73
+ // doesn't parse as a filter and isn't a bare allow-listed attr —
74
+ // the op is skipped entirely. No members key is emitted (no change)
75
+ // and nothing is polluted.
76
+ assert.equal(Object.prototype.hasOwnProperty.call(out, "members"), false);
77
+ assert.equal({}.x, undefined);
78
+ });
79
+ test("add to a non-allowlisted path is ignored", () => {
80
+ const out = applyPatchOps(group(), patch({ op: "add", path: "__proto__", value: { polluted: true } }));
81
+ assert.equal(Object.keys(out).length, 0);
82
+ assert.equal({}.polluted, undefined);
83
+ });
84
+ // --- emails array (same machinery) -----------------------------------
85
+ test("add + remove works on emails too", () => {
86
+ const user = {
87
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
88
+ id: "u1",
89
+ userName: "a@x",
90
+ emails: [{ value: "a@x", primary: true }],
91
+ meta: { resourceType: "User", created: "2026-06-06T00:00:00Z", lastModified: "2026-06-06T00:00:00Z" },
92
+ };
93
+ const added = applyPatchOps(user, patch({ op: "add", path: "emails", value: [{ value: "a2@x" }] }));
94
+ assert.deepEqual(added.emails.map((e) => e.value), ["a@x", "a2@x"]);
95
+ const removed = applyPatchOps(user, patch({ op: "remove", path: 'emails[value eq "a@x"]' }));
96
+ assert.deepEqual(removed.emails.map((e) => e.value), []);
97
+ });
98
+ test("missing target resource throws", () => {
99
+ assert.throws(() => applyPatchOps(undefined, patch({ op: "add", path: "members", value: [] })), /not found/);
100
+ });
@@ -0,0 +1,38 @@
1
+ import { type ScimGroup, type ScimUser } from "./types.js";
2
+ import { type IScimStore } from "./store.js";
3
+ /**
4
+ * Minimal Redis surface we depend on. Matches both `ioredis` and the
5
+ * built-in promisified node-redis client — easier to swap clients
6
+ * and trivial to fake in unit tests.
7
+ */
8
+ export interface RedisLike {
9
+ get(key: string): Promise<string | null>;
10
+ set(key: string, value: string): Promise<unknown>;
11
+ }
12
+ export declare class RedisScimStore implements IScimStore {
13
+ private readonly redis;
14
+ private readonly key;
15
+ private snapshot;
16
+ private bootstrapped;
17
+ private writeQueue;
18
+ constructor(redis: RedisLike, opts?: {
19
+ key?: string;
20
+ });
21
+ load(): Promise<void>;
22
+ listUsers(): ScimUser[];
23
+ getUser(id: string): ScimUser | undefined;
24
+ getUserByUserName(userName: string): ScimUser | undefined;
25
+ createUser(input: Partial<ScimUser>): Promise<ScimUser>;
26
+ updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
27
+ deleteUser(id: string): Promise<boolean>;
28
+ listGroups(): ScimGroup[];
29
+ getGroup(id: string): ScimGroup | undefined;
30
+ createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
31
+ updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
32
+ deleteGroup(id: string): Promise<boolean>;
33
+ groupsContaining(userId: string): Array<{
34
+ value: string;
35
+ display?: string;
36
+ }>;
37
+ private persist;
38
+ }
@@ -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
+ });
@@ -0,0 +1,40 @@
1
+ import type { Application } from "express";
2
+ import { type ScimPatchRequest } from "./types.js";
3
+ import { type IScimStore } from "./store.js";
4
+ export interface ScimRoutesDeps {
5
+ store: IScimStore;
6
+ bearerToken: string;
7
+ /** Audit hook called after every successful mutation. Best-effort. */
8
+ audit?: (event: {
9
+ actor: string;
10
+ action: string;
11
+ target: string;
12
+ result: "ok" | "error";
13
+ status: number;
14
+ }) => void;
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>;