@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.
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +144 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +15 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +33 -11
- package/dist/tools/topology.test.js +45 -0
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +2529 -145
- package/package.json +13 -3
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
// 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);
|
|
31
|
+
if (!m)
|
|
32
|
+
return false;
|
|
33
|
+
const a = Buffer.from(m[1].trim());
|
|
34
|
+
const b = Buffer.from(expected);
|
|
35
|
+
if (a.length !== b.length)
|
|
36
|
+
return false;
|
|
37
|
+
return timingSafeEqual(a, b);
|
|
38
|
+
};
|
|
39
|
+
export function registerScimRoutes(app, deps) {
|
|
40
|
+
const { store, bearerToken, audit } = deps;
|
|
41
|
+
// Auth middleware — scoped to /scim/v2/* only.
|
|
42
|
+
app.use("/scim/v2", (req, res, next) => {
|
|
43
|
+
if (!bearerToken) {
|
|
44
|
+
res.status(503).json(scimError(503, "SCIM is enabled but OMCP_SCIM_TOKEN is unset"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!constantTimeBearerMatch(req.headers["authorization"], bearerToken)) {
|
|
48
|
+
res.status(401).json(scimError(401, "valid SCIM bearer token required"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
next();
|
|
52
|
+
});
|
|
53
|
+
// ---- Discovery endpoints ----
|
|
54
|
+
app.get("/scim/v2/ServiceProviderConfig", (_req, res) => {
|
|
55
|
+
res.json({
|
|
56
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
57
|
+
documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
|
|
58
|
+
patch: { supported: true },
|
|
59
|
+
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
|
60
|
+
filter: { supported: false, maxResults: 200 },
|
|
61
|
+
changePassword: { supported: false },
|
|
62
|
+
sort: { supported: false },
|
|
63
|
+
etag: { supported: false },
|
|
64
|
+
authenticationSchemes: [
|
|
65
|
+
{
|
|
66
|
+
name: "OAuth Bearer Token",
|
|
67
|
+
description: "Authentication via OAuth 2.0 bearer token (configured per-deployment).",
|
|
68
|
+
specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
|
|
69
|
+
documentationUri: "https://thotischner.github.io/observability-mcp/scim-provisioning/",
|
|
70
|
+
type: "oauthbearertoken",
|
|
71
|
+
primary: true,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
app.get("/scim/v2/ResourceTypes", (_req, res) => {
|
|
77
|
+
res.json({
|
|
78
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
79
|
+
totalResults: 2,
|
|
80
|
+
Resources: [
|
|
81
|
+
{
|
|
82
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
83
|
+
id: "User",
|
|
84
|
+
name: "User",
|
|
85
|
+
endpoint: "/Users",
|
|
86
|
+
description: "User account",
|
|
87
|
+
schema: SCIM_SCHEMA_USER,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
91
|
+
id: "Group",
|
|
92
|
+
name: "Group",
|
|
93
|
+
endpoint: "/Groups",
|
|
94
|
+
description: "Group / role mapping",
|
|
95
|
+
schema: SCIM_SCHEMA_GROUP,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
app.get("/scim/v2/Schemas", (_req, res) => {
|
|
101
|
+
res.json({
|
|
102
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
103
|
+
totalResults: 2,
|
|
104
|
+
Resources: [
|
|
105
|
+
{ schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"], id: SCIM_SCHEMA_USER, name: "User" },
|
|
106
|
+
{ schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"], id: SCIM_SCHEMA_GROUP, name: "Group" },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
// ---- Users ----
|
|
111
|
+
app.get("/scim/v2/Users", (_req, res) => {
|
|
112
|
+
const users = store.listUsers().map((u) => withGroups(u, store));
|
|
113
|
+
res.json({
|
|
114
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
115
|
+
totalResults: users.length,
|
|
116
|
+
itemsPerPage: users.length,
|
|
117
|
+
startIndex: 1,
|
|
118
|
+
Resources: users,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
app.get("/scim/v2/Users/:id", (req, res) => {
|
|
122
|
+
const u = store.getUser(req.params.id);
|
|
123
|
+
if (!u) {
|
|
124
|
+
res.status(404).json(scimError(404, `User ${req.params.id} not found`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
res.json(withGroups(u, store));
|
|
128
|
+
});
|
|
129
|
+
app.post("/scim/v2/Users", async (req, res) => {
|
|
130
|
+
try {
|
|
131
|
+
const u = await store.createUser((req.body ?? {}));
|
|
132
|
+
audit?.({ actor: "scim", action: "User.create", target: u.userName, result: "ok", status: 201 });
|
|
133
|
+
res.status(201).json(withGroups(u, store));
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
handleStoreError(e, res, "User.create", audit);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
app.patch("/scim/v2/Users/:id", async (req, res) => {
|
|
140
|
+
try {
|
|
141
|
+
const patch = applyPatchOps(store.getUser(req.params.id), req.body);
|
|
142
|
+
const u = await store.updateUser(req.params.id, patch);
|
|
143
|
+
audit?.({ actor: "scim", action: "User.update", target: u.userName, result: "ok", status: 200 });
|
|
144
|
+
res.json(withGroups(u, store));
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
handleStoreError(e, res, "User.update", audit);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
app.delete("/scim/v2/Users/:id", async (req, res) => {
|
|
151
|
+
const ok = await store.deleteUser(req.params.id);
|
|
152
|
+
audit?.({ actor: "scim", action: "User.delete", target: req.params.id, result: ok ? "ok" : "error", status: ok ? 204 : 404 });
|
|
153
|
+
if (!ok) {
|
|
154
|
+
res.status(404).json(scimError(404, `User ${req.params.id} not found`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
res.status(204).end();
|
|
158
|
+
});
|
|
159
|
+
// ---- Groups ----
|
|
160
|
+
app.get("/scim/v2/Groups", (_req, res) => {
|
|
161
|
+
const groups = store.listGroups();
|
|
162
|
+
res.json({
|
|
163
|
+
schemas: [SCIM_SCHEMA_LIST_RESPONSE],
|
|
164
|
+
totalResults: groups.length,
|
|
165
|
+
itemsPerPage: groups.length,
|
|
166
|
+
startIndex: 1,
|
|
167
|
+
Resources: groups,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
app.get("/scim/v2/Groups/:id", (req, res) => {
|
|
171
|
+
const g = store.getGroup(req.params.id);
|
|
172
|
+
if (!g) {
|
|
173
|
+
res.status(404).json(scimError(404, `Group ${req.params.id} not found`));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
res.json(g);
|
|
177
|
+
});
|
|
178
|
+
app.post("/scim/v2/Groups", async (req, res) => {
|
|
179
|
+
try {
|
|
180
|
+
const g = await store.createGroup((req.body ?? {}));
|
|
181
|
+
audit?.({ actor: "scim", action: "Group.create", target: g.displayName, result: "ok", status: 201 });
|
|
182
|
+
res.status(201).json(g);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
handleStoreError(e, res, "Group.create", audit);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
app.patch("/scim/v2/Groups/:id", async (req, res) => {
|
|
189
|
+
try {
|
|
190
|
+
const patch = applyPatchOps(store.getGroup(req.params.id), req.body);
|
|
191
|
+
const g = await store.updateGroup(req.params.id, patch);
|
|
192
|
+
audit?.({ actor: "scim", action: "Group.update", target: g.displayName, result: "ok", status: 200 });
|
|
193
|
+
res.json(g);
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
handleStoreError(e, res, "Group.update", audit);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
app.delete("/scim/v2/Groups/:id", async (req, res) => {
|
|
200
|
+
const ok = await store.deleteGroup(req.params.id);
|
|
201
|
+
audit?.({ actor: "scim", action: "Group.delete", target: req.params.id, result: ok ? "ok" : "error", status: ok ? 204 : 404 });
|
|
202
|
+
if (!ok) {
|
|
203
|
+
res.status(404).json(scimError(404, `Group ${req.params.id} not found`));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
res.status(204).end();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function withGroups(u, store) {
|
|
210
|
+
return { ...u, groups: store.groupsContaining(u.id) };
|
|
211
|
+
}
|
|
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) {
|
|
286
|
+
if (!current)
|
|
287
|
+
throw new ScimNotFoundError("target resource not found");
|
|
288
|
+
if (!patch?.Operations || !Array.isArray(patch.Operations))
|
|
289
|
+
return {};
|
|
290
|
+
const cur = current;
|
|
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]);
|
|
295
|
+
for (const op of patch.Operations) {
|
|
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;
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
function handleStoreError(e, res, action, audit) {
|
|
381
|
+
if (e instanceof ScimNotFoundError) {
|
|
382
|
+
audit?.({ actor: "scim", action, target: "?", result: "error", status: 404 });
|
|
383
|
+
res.status(404).json(scimError(404, e.message));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (e instanceof ScimValidationError) {
|
|
387
|
+
const status = e.scimType === "uniqueness" ? 409 : 400;
|
|
388
|
+
audit?.({ actor: "scim", action, target: "?", result: "error", status });
|
|
389
|
+
res.status(status).json(scimError(status, e.message, e.scimType));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
console.warn(`[scim] ${action} failed:`, e);
|
|
393
|
+
audit?.({ actor: "scim", action, target: "?", result: "error", status: 500 });
|
|
394
|
+
res.status(500).json(scimError(500, "internal error"));
|
|
395
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ScimGroup, type ScimUser } from "./types.js";
|
|
2
|
+
export interface ScimSnapshot {
|
|
3
|
+
users: ScimUser[];
|
|
4
|
+
groups: ScimGroup[];
|
|
5
|
+
}
|
|
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 {
|
|
30
|
+
private readonly path;
|
|
31
|
+
private snapshot;
|
|
32
|
+
private bootstrapped;
|
|
33
|
+
constructor(path: string);
|
|
34
|
+
load(): Promise<void>;
|
|
35
|
+
listUsers(): ScimUser[];
|
|
36
|
+
getUser(id: string): ScimUser | undefined;
|
|
37
|
+
getUserByUserName(userName: string): ScimUser | undefined;
|
|
38
|
+
createUser(input: Partial<ScimUser>): Promise<ScimUser>;
|
|
39
|
+
updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
|
|
40
|
+
deleteUser(id: string): Promise<boolean>;
|
|
41
|
+
listGroups(): ScimGroup[];
|
|
42
|
+
getGroup(id: string): ScimGroup | undefined;
|
|
43
|
+
createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
44
|
+
updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
45
|
+
deleteGroup(id: string): Promise<boolean>;
|
|
46
|
+
/** Look up the groups a user is currently a member of — used to
|
|
47
|
+
* populate `User.groups` on read responses. */
|
|
48
|
+
groupsContaining(userId: string): Array<{
|
|
49
|
+
value: string;
|
|
50
|
+
display?: string;
|
|
51
|
+
}>;
|
|
52
|
+
private persist;
|
|
53
|
+
}
|
|
54
|
+
export declare class ScimValidationError extends Error {
|
|
55
|
+
readonly scimType?: string | undefined;
|
|
56
|
+
constructor(message: string, scimType?: string | undefined);
|
|
57
|
+
}
|
|
58
|
+
export declare class ScimNotFoundError extends Error {
|
|
59
|
+
constructor(message: string);
|
|
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>;
|
|
@@ -0,0 +1,196 @@
|
|
|
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.
|
|
4
|
+
//
|
|
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.
|
|
9
|
+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { nowIso, SCIM_SCHEMA_GROUP, SCIM_SCHEMA_USER } from "./types.js";
|
|
13
|
+
const EMPTY = { users: [], groups: [] };
|
|
14
|
+
export class ScimStore {
|
|
15
|
+
path;
|
|
16
|
+
snapshot = EMPTY;
|
|
17
|
+
bootstrapped = null;
|
|
18
|
+
constructor(path) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
}
|
|
21
|
+
async load() {
|
|
22
|
+
if (this.bootstrapped)
|
|
23
|
+
return this.bootstrapped;
|
|
24
|
+
this.bootstrapped = (async () => {
|
|
25
|
+
try {
|
|
26
|
+
const raw = await readFile(this.path, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
this.snapshot = {
|
|
29
|
+
users: Array.isArray(parsed.users) ? parsed.users : [],
|
|
30
|
+
groups: Array.isArray(parsed.groups) ? parsed.groups : [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err.code === "ENOENT") {
|
|
35
|
+
this.snapshot = { users: [], groups: [] };
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.warn(`[scim] failed to load ${this.path}: ${err.message} — starting empty`);
|
|
39
|
+
this.snapshot = { users: [], groups: [] };
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
return this.bootstrapped;
|
|
43
|
+
}
|
|
44
|
+
listUsers() {
|
|
45
|
+
return this.snapshot.users.slice();
|
|
46
|
+
}
|
|
47
|
+
getUser(id) {
|
|
48
|
+
return this.snapshot.users.find((u) => u.id === id);
|
|
49
|
+
}
|
|
50
|
+
getUserByUserName(userName) {
|
|
51
|
+
return this.snapshot.users.find((u) => u.userName === userName);
|
|
52
|
+
}
|
|
53
|
+
async createUser(input) {
|
|
54
|
+
if (!input.userName)
|
|
55
|
+
throw new ScimValidationError("userName is required");
|
|
56
|
+
if (this.getUserByUserName(input.userName)) {
|
|
57
|
+
throw new ScimValidationError(`User with userName '${input.userName}' already exists`, "uniqueness");
|
|
58
|
+
}
|
|
59
|
+
const ts = nowIso();
|
|
60
|
+
const user = {
|
|
61
|
+
schemas: [SCIM_SCHEMA_USER],
|
|
62
|
+
id: randomUUID(),
|
|
63
|
+
userName: input.userName,
|
|
64
|
+
active: input.active ?? true,
|
|
65
|
+
displayName: input.displayName,
|
|
66
|
+
name: input.name,
|
|
67
|
+
emails: input.emails,
|
|
68
|
+
externalId: input.externalId,
|
|
69
|
+
meta: { resourceType: "User", created: ts, lastModified: ts },
|
|
70
|
+
};
|
|
71
|
+
this.snapshot.users.push(user);
|
|
72
|
+
await this.persist();
|
|
73
|
+
return user;
|
|
74
|
+
}
|
|
75
|
+
async updateUser(id, patch) {
|
|
76
|
+
const i = this.snapshot.users.findIndex((u) => u.id === id);
|
|
77
|
+
if (i < 0)
|
|
78
|
+
throw new ScimNotFoundError(`User ${id} not found`);
|
|
79
|
+
const next = {
|
|
80
|
+
...this.snapshot.users[i],
|
|
81
|
+
...patch,
|
|
82
|
+
schemas: [SCIM_SCHEMA_USER],
|
|
83
|
+
id,
|
|
84
|
+
meta: {
|
|
85
|
+
...this.snapshot.users[i].meta,
|
|
86
|
+
lastModified: nowIso(),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
this.snapshot.users[i] = next;
|
|
90
|
+
await this.persist();
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
async deleteUser(id) {
|
|
94
|
+
const before = this.snapshot.users.length;
|
|
95
|
+
this.snapshot.users = this.snapshot.users.filter((u) => u.id !== id);
|
|
96
|
+
if (this.snapshot.users.length === before)
|
|
97
|
+
return false;
|
|
98
|
+
// Also remove the user from every group's members list.
|
|
99
|
+
for (const g of this.snapshot.groups) {
|
|
100
|
+
g.members = (g.members ?? []).filter((m) => m.value !== id);
|
|
101
|
+
}
|
|
102
|
+
await this.persist();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
listGroups() {
|
|
106
|
+
return this.snapshot.groups.slice();
|
|
107
|
+
}
|
|
108
|
+
getGroup(id) {
|
|
109
|
+
return this.snapshot.groups.find((g) => g.id === id);
|
|
110
|
+
}
|
|
111
|
+
async createGroup(input) {
|
|
112
|
+
if (!input.displayName)
|
|
113
|
+
throw new ScimValidationError("displayName is required");
|
|
114
|
+
const ts = nowIso();
|
|
115
|
+
const group = {
|
|
116
|
+
schemas: [SCIM_SCHEMA_GROUP],
|
|
117
|
+
id: randomUUID(),
|
|
118
|
+
displayName: input.displayName,
|
|
119
|
+
members: input.members ?? [],
|
|
120
|
+
externalId: input.externalId,
|
|
121
|
+
meta: { resourceType: "Group", created: ts, lastModified: ts },
|
|
122
|
+
};
|
|
123
|
+
this.snapshot.groups.push(group);
|
|
124
|
+
await this.persist();
|
|
125
|
+
return group;
|
|
126
|
+
}
|
|
127
|
+
async updateGroup(id, patch) {
|
|
128
|
+
const i = this.snapshot.groups.findIndex((g) => g.id === id);
|
|
129
|
+
if (i < 0)
|
|
130
|
+
throw new ScimNotFoundError(`Group ${id} not found`);
|
|
131
|
+
const next = {
|
|
132
|
+
...this.snapshot.groups[i],
|
|
133
|
+
...patch,
|
|
134
|
+
schemas: [SCIM_SCHEMA_GROUP],
|
|
135
|
+
id,
|
|
136
|
+
meta: {
|
|
137
|
+
...this.snapshot.groups[i].meta,
|
|
138
|
+
lastModified: nowIso(),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
this.snapshot.groups[i] = next;
|
|
142
|
+
await this.persist();
|
|
143
|
+
return next;
|
|
144
|
+
}
|
|
145
|
+
async deleteGroup(id) {
|
|
146
|
+
const before = this.snapshot.groups.length;
|
|
147
|
+
this.snapshot.groups = this.snapshot.groups.filter((g) => g.id !== id);
|
|
148
|
+
if (this.snapshot.groups.length === before)
|
|
149
|
+
return false;
|
|
150
|
+
await this.persist();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/** Look up the groups a user is currently a member of — used to
|
|
154
|
+
* populate `User.groups` on read responses. */
|
|
155
|
+
groupsContaining(userId) {
|
|
156
|
+
return this.snapshot.groups
|
|
157
|
+
.filter((g) => (g.members ?? []).some((m) => m.value === userId))
|
|
158
|
+
.map((g) => ({ value: g.id, display: g.displayName }));
|
|
159
|
+
}
|
|
160
|
+
async persist() {
|
|
161
|
+
await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
|
|
162
|
+
const tmp = `${this.path}.tmp`;
|
|
163
|
+
await writeFile(tmp, JSON.stringify(this.snapshot, null, 2), { mode: 0o600 });
|
|
164
|
+
await rename(tmp, this.path);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export class ScimValidationError extends Error {
|
|
168
|
+
scimType;
|
|
169
|
+
constructor(message, scimType) {
|
|
170
|
+
super(message);
|
|
171
|
+
this.scimType = scimType;
|
|
172
|
+
this.name = "ScimValidationError";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export class ScimNotFoundError extends Error {
|
|
176
|
+
constructor(message) {
|
|
177
|
+
super(message);
|
|
178
|
+
this.name = "ScimNotFoundError";
|
|
179
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|