@thotischner/observability-mcp 1.7.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -0,0 +1,249 @@
1
+ // SCIM 2.0 routes — mounted at /scim/v2/.
2
+ //
3
+ // Spec subset covered:
4
+ // GET /scim/v2/ServiceProviderConfig
5
+ // GET /scim/v2/ResourceTypes
6
+ // GET /scim/v2/Schemas
7
+ // GET /scim/v2/Users list (no filter support yet)
8
+ // GET /scim/v2/Users/:id
9
+ // POST /scim/v2/Users
10
+ // PATCH /scim/v2/Users/:id minimal: replace top-level attrs
11
+ // DELETE /scim/v2/Users/:id
12
+ // GET /scim/v2/Groups
13
+ // GET /scim/v2/Groups/:id
14
+ // POST /scim/v2/Groups
15
+ // PATCH /scim/v2/Groups/:id
16
+ // DELETE /scim/v2/Groups/:id
17
+ //
18
+ // Auth: Bearer token via OMCP_SCIM_TOKEN; absence of OMCP_SCIM_TOKEN
19
+ // rejects every request (the routes are not safe without it).
20
+ import { timingSafeEqual } from "node:crypto";
21
+ import { SCIM_SCHEMA_LIST_RESPONSE, SCIM_SCHEMA_USER, SCIM_SCHEMA_GROUP, scimError, } from "./types.js";
22
+ import { ScimNotFoundError, ScimValidationError } from "./store.js";
23
+ const constantTimeBearerMatch = (raw, expected) => {
24
+ if (!raw)
25
+ return false;
26
+ const m = raw.match(/^Bearer\s+(.+)$/i);
27
+ if (!m)
28
+ return false;
29
+ const a = Buffer.from(m[1].trim());
30
+ const b = Buffer.from(expected);
31
+ if (a.length !== b.length)
32
+ return false;
33
+ return timingSafeEqual(a, b);
34
+ };
35
+ export function registerScimRoutes(app, deps) {
36
+ const { store, bearerToken, audit } = deps;
37
+ // Auth middleware — scoped to /scim/v2/* only.
38
+ app.use("/scim/v2", (req, res, next) => {
39
+ if (!bearerToken) {
40
+ res.status(503).json(scimError(503, "SCIM is enabled but OMCP_SCIM_TOKEN is unset"));
41
+ return;
42
+ }
43
+ if (!constantTimeBearerMatch(req.headers["authorization"], bearerToken)) {
44
+ res.status(401).json(scimError(401, "valid SCIM bearer token required"));
45
+ return;
46
+ }
47
+ next();
48
+ });
49
+ // ---- Discovery endpoints ----
50
+ app.get("/scim/v2/ServiceProviderConfig", (_req, res) => {
51
+ res.json({
52
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
53
+ documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
54
+ patch: { supported: true },
55
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
56
+ filter: { supported: false, maxResults: 200 },
57
+ changePassword: { supported: false },
58
+ sort: { supported: false },
59
+ etag: { supported: false },
60
+ authenticationSchemes: [
61
+ {
62
+ name: "OAuth Bearer Token",
63
+ description: "Authentication via OAuth 2.0 bearer token (configured per-deployment).",
64
+ specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
65
+ documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
66
+ type: "oauthbearertoken",
67
+ primary: true,
68
+ },
69
+ ],
70
+ });
71
+ });
72
+ app.get("/scim/v2/ResourceTypes", (_req, res) => {
73
+ res.json({
74
+ schemas: [SCIM_SCHEMA_LIST_RESPONSE],
75
+ totalResults: 2,
76
+ Resources: [
77
+ {
78
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
79
+ id: "User",
80
+ name: "User",
81
+ endpoint: "/Users",
82
+ description: "User account",
83
+ schema: SCIM_SCHEMA_USER,
84
+ },
85
+ {
86
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
87
+ id: "Group",
88
+ name: "Group",
89
+ endpoint: "/Groups",
90
+ description: "Group / role mapping",
91
+ schema: SCIM_SCHEMA_GROUP,
92
+ },
93
+ ],
94
+ });
95
+ });
96
+ app.get("/scim/v2/Schemas", (_req, res) => {
97
+ res.json({
98
+ schemas: [SCIM_SCHEMA_LIST_RESPONSE],
99
+ totalResults: 2,
100
+ Resources: [
101
+ { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"], id: SCIM_SCHEMA_USER, name: "User" },
102
+ { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"], id: SCIM_SCHEMA_GROUP, name: "Group" },
103
+ ],
104
+ });
105
+ });
106
+ // ---- Users ----
107
+ app.get("/scim/v2/Users", (_req, res) => {
108
+ const users = store.listUsers().map((u) => withGroups(u, store));
109
+ res.json({
110
+ schemas: [SCIM_SCHEMA_LIST_RESPONSE],
111
+ totalResults: users.length,
112
+ itemsPerPage: users.length,
113
+ startIndex: 1,
114
+ Resources: users,
115
+ });
116
+ });
117
+ app.get("/scim/v2/Users/:id", (req, res) => {
118
+ const u = store.getUser(req.params.id);
119
+ if (!u) {
120
+ res.status(404).json(scimError(404, `User ${req.params.id} not found`));
121
+ return;
122
+ }
123
+ res.json(withGroups(u, store));
124
+ });
125
+ app.post("/scim/v2/Users", async (req, res) => {
126
+ try {
127
+ const u = await store.createUser((req.body ?? {}));
128
+ audit?.({ actor: "scim", action: "User.create", target: u.userName, result: "ok", status: 201 });
129
+ res.status(201).json(withGroups(u, store));
130
+ }
131
+ catch (e) {
132
+ handleStoreError(e, res, "User.create", audit);
133
+ }
134
+ });
135
+ app.patch("/scim/v2/Users/:id", async (req, res) => {
136
+ try {
137
+ const patch = applyPatchOps(store.getUser(req.params.id), req.body);
138
+ const u = await store.updateUser(req.params.id, patch);
139
+ audit?.({ actor: "scim", action: "User.update", target: u.userName, result: "ok", status: 200 });
140
+ res.json(withGroups(u, store));
141
+ }
142
+ catch (e) {
143
+ handleStoreError(e, res, "User.update", audit);
144
+ }
145
+ });
146
+ app.delete("/scim/v2/Users/:id", async (req, res) => {
147
+ const ok = await store.deleteUser(req.params.id);
148
+ audit?.({ actor: "scim", action: "User.delete", target: req.params.id, result: ok ? "ok" : "error", status: ok ? 204 : 404 });
149
+ if (!ok) {
150
+ res.status(404).json(scimError(404, `User ${req.params.id} not found`));
151
+ return;
152
+ }
153
+ res.status(204).end();
154
+ });
155
+ // ---- Groups ----
156
+ app.get("/scim/v2/Groups", (_req, res) => {
157
+ const groups = store.listGroups();
158
+ res.json({
159
+ schemas: [SCIM_SCHEMA_LIST_RESPONSE],
160
+ totalResults: groups.length,
161
+ itemsPerPage: groups.length,
162
+ startIndex: 1,
163
+ Resources: groups,
164
+ });
165
+ });
166
+ app.get("/scim/v2/Groups/:id", (req, res) => {
167
+ const g = store.getGroup(req.params.id);
168
+ if (!g) {
169
+ res.status(404).json(scimError(404, `Group ${req.params.id} not found`));
170
+ return;
171
+ }
172
+ res.json(g);
173
+ });
174
+ app.post("/scim/v2/Groups", async (req, res) => {
175
+ try {
176
+ const g = await store.createGroup((req.body ?? {}));
177
+ audit?.({ actor: "scim", action: "Group.create", target: g.displayName, result: "ok", status: 201 });
178
+ res.status(201).json(g);
179
+ }
180
+ catch (e) {
181
+ handleStoreError(e, res, "Group.create", audit);
182
+ }
183
+ });
184
+ app.patch("/scim/v2/Groups/:id", async (req, res) => {
185
+ try {
186
+ const patch = applyPatchOps(store.getGroup(req.params.id), req.body);
187
+ const g = await store.updateGroup(req.params.id, patch);
188
+ audit?.({ actor: "scim", action: "Group.update", target: g.displayName, result: "ok", status: 200 });
189
+ res.json(g);
190
+ }
191
+ catch (e) {
192
+ handleStoreError(e, res, "Group.update", audit);
193
+ }
194
+ });
195
+ app.delete("/scim/v2/Groups/:id", async (req, res) => {
196
+ const ok = await store.deleteGroup(req.params.id);
197
+ audit?.({ actor: "scim", action: "Group.delete", target: req.params.id, result: ok ? "ok" : "error", status: ok ? 204 : 404 });
198
+ if (!ok) {
199
+ res.status(404).json(scimError(404, `Group ${req.params.id} not found`));
200
+ return;
201
+ }
202
+ res.status(204).end();
203
+ });
204
+ }
205
+ function withGroups(u, store) {
206
+ return { ...u, groups: store.groupsContaining(u.id) };
207
+ }
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) {
215
+ if (!current)
216
+ throw new ScimNotFoundError("target resource not found");
217
+ if (!patch?.Operations || !Array.isArray(patch.Operations))
218
+ return {};
219
+ const out = {};
220
+ 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);
227
+ }
228
+ continue;
229
+ }
230
+ out[op.path] = op.value;
231
+ }
232
+ return out;
233
+ }
234
+ function handleStoreError(e, res, action, audit) {
235
+ if (e instanceof ScimNotFoundError) {
236
+ audit?.({ actor: "scim", action, target: "?", result: "error", status: 404 });
237
+ res.status(404).json(scimError(404, e.message));
238
+ return;
239
+ }
240
+ if (e instanceof ScimValidationError) {
241
+ const status = e.scimType === "uniqueness" ? 409 : 400;
242
+ audit?.({ actor: "scim", action, target: "?", result: "error", status });
243
+ res.status(status).json(scimError(status, e.message, e.scimType));
244
+ return;
245
+ }
246
+ console.warn(`[scim] ${action} failed:`, e);
247
+ audit?.({ actor: "scim", action, target: "?", result: "error", status: 500 });
248
+ res.status(500).json(scimError(500, "internal error"));
249
+ }
@@ -0,0 +1,37 @@
1
+ import { type ScimGroup, type ScimUser } from "./types.js";
2
+ export interface ScimSnapshot {
3
+ users: ScimUser[];
4
+ groups: ScimGroup[];
5
+ }
6
+ export declare class ScimStore {
7
+ private readonly path;
8
+ private snapshot;
9
+ private bootstrapped;
10
+ constructor(path: string);
11
+ load(): Promise<void>;
12
+ listUsers(): ScimUser[];
13
+ getUser(id: string): ScimUser | undefined;
14
+ getUserByUserName(userName: string): ScimUser | undefined;
15
+ createUser(input: Partial<ScimUser>): Promise<ScimUser>;
16
+ updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
17
+ deleteUser(id: string): Promise<boolean>;
18
+ listGroups(): ScimGroup[];
19
+ getGroup(id: string): ScimGroup | undefined;
20
+ createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
21
+ updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
22
+ deleteGroup(id: string): Promise<boolean>;
23
+ /** Look up the groups a user is currently a member of — used to
24
+ * populate `User.groups` on read responses. */
25
+ groupsContaining(userId: string): Array<{
26
+ value: string;
27
+ display?: string;
28
+ }>;
29
+ private persist;
30
+ }
31
+ export declare class ScimValidationError extends Error {
32
+ readonly scimType?: string | undefined;
33
+ constructor(message: string, scimType?: string | undefined);
34
+ }
35
+ export declare class ScimNotFoundError extends Error {
36
+ constructor(message: string);
37
+ }
@@ -0,0 +1,178 @@
1
+ // SCIM store — file-backed JSON for users + groups.
2
+ //
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.
7
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import { nowIso, SCIM_SCHEMA_GROUP, SCIM_SCHEMA_USER } from "./types.js";
11
+ const EMPTY = { users: [], groups: [] };
12
+ export class ScimStore {
13
+ path;
14
+ snapshot = EMPTY;
15
+ bootstrapped = null;
16
+ constructor(path) {
17
+ this.path = path;
18
+ }
19
+ async load() {
20
+ if (this.bootstrapped)
21
+ return this.bootstrapped;
22
+ this.bootstrapped = (async () => {
23
+ try {
24
+ const raw = await readFile(this.path, "utf8");
25
+ const parsed = JSON.parse(raw);
26
+ this.snapshot = {
27
+ users: Array.isArray(parsed.users) ? parsed.users : [],
28
+ groups: Array.isArray(parsed.groups) ? parsed.groups : [],
29
+ };
30
+ }
31
+ catch (err) {
32
+ if (err.code === "ENOENT") {
33
+ this.snapshot = { users: [], groups: [] };
34
+ return;
35
+ }
36
+ console.warn(`[scim] failed to load ${this.path}: ${err.message} — starting empty`);
37
+ this.snapshot = { users: [], groups: [] };
38
+ }
39
+ })();
40
+ return this.bootstrapped;
41
+ }
42
+ listUsers() {
43
+ return this.snapshot.users.slice();
44
+ }
45
+ getUser(id) {
46
+ return this.snapshot.users.find((u) => u.id === id);
47
+ }
48
+ getUserByUserName(userName) {
49
+ return this.snapshot.users.find((u) => u.userName === userName);
50
+ }
51
+ async createUser(input) {
52
+ if (!input.userName)
53
+ throw new ScimValidationError("userName is required");
54
+ if (this.getUserByUserName(input.userName)) {
55
+ throw new ScimValidationError(`User with userName '${input.userName}' already exists`, "uniqueness");
56
+ }
57
+ const ts = nowIso();
58
+ const user = {
59
+ schemas: [SCIM_SCHEMA_USER],
60
+ id: randomUUID(),
61
+ userName: input.userName,
62
+ active: input.active ?? true,
63
+ displayName: input.displayName,
64
+ name: input.name,
65
+ emails: input.emails,
66
+ externalId: input.externalId,
67
+ meta: { resourceType: "User", created: ts, lastModified: ts },
68
+ };
69
+ this.snapshot.users.push(user);
70
+ await this.persist();
71
+ return user;
72
+ }
73
+ async updateUser(id, patch) {
74
+ const i = this.snapshot.users.findIndex((u) => u.id === id);
75
+ if (i < 0)
76
+ throw new ScimNotFoundError(`User ${id} not found`);
77
+ const next = {
78
+ ...this.snapshot.users[i],
79
+ ...patch,
80
+ schemas: [SCIM_SCHEMA_USER],
81
+ id,
82
+ meta: {
83
+ ...this.snapshot.users[i].meta,
84
+ lastModified: nowIso(),
85
+ },
86
+ };
87
+ this.snapshot.users[i] = next;
88
+ await this.persist();
89
+ return next;
90
+ }
91
+ async deleteUser(id) {
92
+ const before = this.snapshot.users.length;
93
+ this.snapshot.users = this.snapshot.users.filter((u) => u.id !== id);
94
+ if (this.snapshot.users.length === before)
95
+ return false;
96
+ // Also remove the user from every group's members list.
97
+ for (const g of this.snapshot.groups) {
98
+ g.members = (g.members ?? []).filter((m) => m.value !== id);
99
+ }
100
+ await this.persist();
101
+ return true;
102
+ }
103
+ listGroups() {
104
+ return this.snapshot.groups.slice();
105
+ }
106
+ getGroup(id) {
107
+ return this.snapshot.groups.find((g) => g.id === id);
108
+ }
109
+ async createGroup(input) {
110
+ if (!input.displayName)
111
+ throw new ScimValidationError("displayName is required");
112
+ const ts = nowIso();
113
+ const group = {
114
+ schemas: [SCIM_SCHEMA_GROUP],
115
+ id: randomUUID(),
116
+ displayName: input.displayName,
117
+ members: input.members ?? [],
118
+ externalId: input.externalId,
119
+ meta: { resourceType: "Group", created: ts, lastModified: ts },
120
+ };
121
+ this.snapshot.groups.push(group);
122
+ await this.persist();
123
+ return group;
124
+ }
125
+ async updateGroup(id, patch) {
126
+ const i = this.snapshot.groups.findIndex((g) => g.id === id);
127
+ if (i < 0)
128
+ throw new ScimNotFoundError(`Group ${id} not found`);
129
+ const next = {
130
+ ...this.snapshot.groups[i],
131
+ ...patch,
132
+ schemas: [SCIM_SCHEMA_GROUP],
133
+ id,
134
+ meta: {
135
+ ...this.snapshot.groups[i].meta,
136
+ lastModified: nowIso(),
137
+ },
138
+ };
139
+ this.snapshot.groups[i] = next;
140
+ await this.persist();
141
+ return next;
142
+ }
143
+ async deleteGroup(id) {
144
+ const before = this.snapshot.groups.length;
145
+ this.snapshot.groups = this.snapshot.groups.filter((g) => g.id !== id);
146
+ if (this.snapshot.groups.length === before)
147
+ return false;
148
+ await this.persist();
149
+ return true;
150
+ }
151
+ /** Look up the groups a user is currently a member of — used to
152
+ * populate `User.groups` on read responses. */
153
+ groupsContaining(userId) {
154
+ return this.snapshot.groups
155
+ .filter((g) => (g.members ?? []).some((m) => m.value === userId))
156
+ .map((g) => ({ value: g.id, display: g.displayName }));
157
+ }
158
+ async persist() {
159
+ await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
160
+ const tmp = `${this.path}.tmp`;
161
+ await writeFile(tmp, JSON.stringify(this.snapshot, null, 2), { mode: 0o600 });
162
+ await rename(tmp, this.path);
163
+ }
164
+ }
165
+ export class ScimValidationError extends Error {
166
+ scimType;
167
+ constructor(message, scimType) {
168
+ super(message);
169
+ this.scimType = scimType;
170
+ this.name = "ScimValidationError";
171
+ }
172
+ }
173
+ export class ScimNotFoundError extends Error {
174
+ constructor(message) {
175
+ super(message);
176
+ this.name = "ScimNotFoundError";
177
+ }
178
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, statSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { ScimStore, ScimValidationError, ScimNotFoundError } from "./store.js";
7
+ function tmpStore() {
8
+ return join(mkdtempSync(join(tmpdir(), "scim-")), "scim.json");
9
+ }
10
+ test("ScimStore: load() on missing file → empty snapshot", async () => {
11
+ const s = new ScimStore(tmpStore());
12
+ await s.load();
13
+ assert.deepEqual(s.listUsers(), []);
14
+ assert.deepEqual(s.listGroups(), []);
15
+ });
16
+ test("ScimStore: createUser issues UUID id, sets schemas/meta, active=true default", async () => {
17
+ const s = new ScimStore(tmpStore());
18
+ await s.load();
19
+ const u = await s.createUser({ userName: "alice@example.com" });
20
+ assert.match(u.id, /^[0-9a-f-]{36}$/);
21
+ assert.deepEqual(u.schemas, ["urn:ietf:params:scim:schemas:core:2.0:User"]);
22
+ assert.equal(u.userName, "alice@example.com");
23
+ assert.equal(u.active, true);
24
+ assert.equal(u.meta.resourceType, "User");
25
+ });
26
+ test("ScimStore: createUser rejects duplicate userName with uniqueness scimType", async () => {
27
+ const s = new ScimStore(tmpStore());
28
+ await s.load();
29
+ await s.createUser({ userName: "alice@example.com" });
30
+ await assert.rejects(() => s.createUser({ userName: "alice@example.com" }), (e) => e instanceof ScimValidationError && e.scimType === "uniqueness");
31
+ });
32
+ test("ScimStore: createUser rejects missing userName", async () => {
33
+ const s = new ScimStore(tmpStore());
34
+ await s.load();
35
+ await assert.rejects(() => s.createUser({}), ScimValidationError);
36
+ });
37
+ test("ScimStore: getUser / getUserByUserName lookups", async () => {
38
+ const s = new ScimStore(tmpStore());
39
+ await s.load();
40
+ const u = await s.createUser({ userName: "alice@example.com" });
41
+ assert.equal(s.getUser(u.id)?.id, u.id);
42
+ assert.equal(s.getUserByUserName("alice@example.com")?.id, u.id);
43
+ assert.equal(s.getUser("nope"), undefined);
44
+ });
45
+ test("ScimStore: updateUser merges patch + bumps lastModified", async () => {
46
+ const s = new ScimStore(tmpStore());
47
+ await s.load();
48
+ const u = await s.createUser({ userName: "alice@example.com" });
49
+ const created = u.meta.lastModified;
50
+ await new Promise((r) => setTimeout(r, 5));
51
+ const updated = await s.updateUser(u.id, { displayName: "Alice" });
52
+ assert.equal(updated.displayName, "Alice");
53
+ assert.equal(updated.userName, "alice@example.com");
54
+ assert.notEqual(updated.meta.lastModified, created);
55
+ });
56
+ test("ScimStore: updateUser on missing id throws NotFound", async () => {
57
+ const s = new ScimStore(tmpStore());
58
+ await s.load();
59
+ await assert.rejects(() => s.updateUser("nope", { displayName: "x" }), ScimNotFoundError);
60
+ });
61
+ test("ScimStore: deleteUser removes user + scrubs them from group members", async () => {
62
+ const s = new ScimStore(tmpStore());
63
+ await s.load();
64
+ const u = await s.createUser({ userName: "alice@example.com" });
65
+ const g = await s.createGroup({
66
+ displayName: "admins",
67
+ members: [{ value: u.id, display: "Alice" }],
68
+ });
69
+ assert.equal(await s.deleteUser(u.id), true);
70
+ assert.equal(s.getUser(u.id), undefined);
71
+ const refreshed = s.getGroup(g.id);
72
+ assert.deepEqual(refreshed?.members, []);
73
+ });
74
+ test("ScimStore: deleteUser missing → false", async () => {
75
+ const s = new ScimStore(tmpStore());
76
+ await s.load();
77
+ assert.equal(await s.deleteUser("nope"), false);
78
+ });
79
+ test("ScimStore: createGroup with displayName + member list", async () => {
80
+ const s = new ScimStore(tmpStore());
81
+ await s.load();
82
+ const u = await s.createUser({ userName: "alice@example.com" });
83
+ const g = await s.createGroup({
84
+ displayName: "admins",
85
+ members: [{ value: u.id, display: "Alice" }],
86
+ });
87
+ assert.equal(g.displayName, "admins");
88
+ assert.equal(g.members?.length, 1);
89
+ });
90
+ test("ScimStore: groupsContaining(userId) returns the groups a user is in", async () => {
91
+ const s = new ScimStore(tmpStore());
92
+ await s.load();
93
+ const u = await s.createUser({ userName: "alice@example.com" });
94
+ await s.createGroup({ displayName: "admins", members: [{ value: u.id }] });
95
+ await s.createGroup({ displayName: "viewers", members: [{ value: u.id }] });
96
+ await s.createGroup({ displayName: "irrelevant", members: [] });
97
+ const got = s.groupsContaining(u.id);
98
+ assert.equal(got.length, 2);
99
+ assert.deepEqual(got.map((g) => g.display).sort(), ["admins", "viewers"]);
100
+ });
101
+ test("ScimStore: persists to disk with mode 0o600 (atomic tmp+rename)", async () => {
102
+ const path = tmpStore();
103
+ const s = new ScimStore(path);
104
+ await s.load();
105
+ await s.createUser({ userName: "alice@example.com" });
106
+ assert.ok(existsSync(path));
107
+ const mode = statSync(path).mode & 0o777;
108
+ assert.equal(mode, 0o600, `mode 0${mode.toString(8)} != 0600`);
109
+ });
110
+ test("ScimStore: round-trip through disk (load after persist sees the entries)", async () => {
111
+ const path = tmpStore();
112
+ const a = new ScimStore(path);
113
+ await a.load();
114
+ await a.createUser({ userName: "alice@example.com" });
115
+ await a.createGroup({ displayName: "admins", members: [{ value: a.listUsers()[0].id }] });
116
+ const b = new ScimStore(path);
117
+ await b.load();
118
+ assert.equal(b.listUsers().length, 1);
119
+ assert.equal(b.listGroups().length, 1);
120
+ assert.equal(b.listUsers()[0].userName, "alice@example.com");
121
+ });
@@ -0,0 +1,73 @@
1
+ export declare const SCIM_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
2
+ export declare const SCIM_SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
3
+ export declare const SCIM_SCHEMA_PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
4
+ export declare const SCIM_SCHEMA_LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
5
+ export declare const SCIM_SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
6
+ export interface ScimMeta {
7
+ resourceType: "User" | "Group";
8
+ created: string;
9
+ lastModified: string;
10
+ location?: string;
11
+ version?: string;
12
+ }
13
+ export interface ScimUser {
14
+ schemas: string[];
15
+ id: string;
16
+ userName: string;
17
+ active?: boolean;
18
+ displayName?: string;
19
+ name?: {
20
+ givenName?: string;
21
+ familyName?: string;
22
+ formatted?: string;
23
+ };
24
+ emails?: Array<{
25
+ value: string;
26
+ primary?: boolean;
27
+ type?: string;
28
+ }>;
29
+ /** SCIM `groups` is read-only — populated server-side from the
30
+ * group→members linkage. */
31
+ groups?: Array<{
32
+ value: string;
33
+ display?: string;
34
+ }>;
35
+ externalId?: string;
36
+ meta: ScimMeta;
37
+ }
38
+ export interface ScimGroup {
39
+ schemas: string[];
40
+ id: string;
41
+ displayName: string;
42
+ members?: Array<{
43
+ value: string;
44
+ display?: string;
45
+ type?: "User" | "Group";
46
+ }>;
47
+ externalId?: string;
48
+ meta: ScimMeta;
49
+ }
50
+ export interface ScimListResponse<T> {
51
+ schemas: string[];
52
+ totalResults: number;
53
+ Resources: T[];
54
+ startIndex?: number;
55
+ itemsPerPage?: number;
56
+ }
57
+ export interface ScimError {
58
+ schemas: string[];
59
+ status: string;
60
+ scimType?: string;
61
+ detail?: string;
62
+ }
63
+ export interface ScimPatchOperation {
64
+ op: "add" | "remove" | "replace";
65
+ path?: string;
66
+ value?: unknown;
67
+ }
68
+ export interface ScimPatchRequest {
69
+ schemas: string[];
70
+ Operations: ScimPatchOperation[];
71
+ }
72
+ export declare function scimError(status: number, detail: string, scimType?: string): ScimError;
73
+ export declare function nowIso(): string;
@@ -0,0 +1,29 @@
1
+ // SCIM 2.0 — minimal shared types.
2
+ //
3
+ // The gateway implements the subset of SCIM 2.0 that the most-common
4
+ // IdPs (Entra ID, Okta) use to provision Users and Groups. We do NOT
5
+ // aim for full RFC 7643 / 7644 compliance — only the methods the
6
+ // provisioning checklists exercise:
7
+ //
8
+ // Users: GET (list+by-id), POST, PATCH, DELETE
9
+ // Groups: GET (list+by-id), POST, PATCH, DELETE
10
+ // Discovery: ServiceProviderConfig, ResourceTypes, Schemas
11
+ //
12
+ // Other operations (PUT/replace, Bulk, search-via-POST) are deferred
13
+ // until an IdP customer explicitly requires them.
14
+ export const SCIM_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
15
+ export const SCIM_SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
16
+ export const SCIM_SCHEMA_PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
17
+ export const SCIM_SCHEMA_LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
18
+ export const SCIM_SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
19
+ export function scimError(status, detail, scimType) {
20
+ return {
21
+ schemas: [SCIM_SCHEMA_ERROR],
22
+ status: String(status),
23
+ scimType,
24
+ detail,
25
+ };
26
+ }
27
+ export function nowIso() {
28
+ return new Date().toISOString();
29
+ }