@classytic/arc 2.11.4 → 2.13.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/README.md +16 -12
- package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
- package/dist/EventTransport-CT_52aWU.d.mts +34 -0
- package/dist/EventTransport-DLWoUMHy.mjs +103 -0
- package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/audit.d.mts +199 -0
- package/dist/auth/audit.mjs +288 -0
- package/dist/auth/index.d.mts +3 -3
- package/dist/auth/index.mjs +117 -191
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +89 -13
- package/dist/cli/commands/describe.mjs +56 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +147 -48
- package/dist/cli/commands/init.d.mts +13 -0
- package/dist/cli/commands/init.mjs +130 -87
- package/dist/cli/commands/introspect.mjs +8 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/core-D72ia0EH.mjs +1399 -0
- package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
- package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
- package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
- package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
- package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
- package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
- package/dist/errors-j4aJm1Wg.mjs +184 -0
- package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
- package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
- package/dist/events/index.d.mts +6 -6
- package/dist/events/index.mjs +11 -35
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +1 -1
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
- package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
- package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
- package/dist/index.d.mts +6 -7
- package/dist/index.mjs +9 -10
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +60 -11
- package/dist/integrations/streamline.mjs +75 -85
- package/dist/integrations/websocket.mjs +2 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +2 -2
- package/dist/migrations/index.d.mts +23 -3
- package/dist/migrations/index.mjs +0 -7
- package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
- package/dist/{openapi-D7G1V7ex.mjs → openapi-CiOMVW1p.mjs} +143 -13
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
- package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +16 -31
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +6 -9
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +6 -8
- package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
- package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-C5coh64w.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
- package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
- package/dist/schemas/index.d.mts +100 -30
- package/dist/schemas/index.mjs +86 -29
- package/dist/scim/index.d.mts +264 -0
- package/dist/scim/index.mjs +963 -0
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +4 -4
- package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
- package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -8
- package/dist/testing/index.mjs +16 -24
- package/dist/types/index.d.mts +4 -4
- package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
- package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +5 -5
- package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
- package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +147 -51
- package/skills/arc/references/agent-auth.md +238 -0
- package/skills/arc/references/api-reference.md +187 -0
- package/skills/arc/references/auth.md +354 -7
- package/skills/arc/references/enterprise-auth.md +94 -0
- package/skills/arc/references/events.md +8 -6
- package/skills/arc/references/mcp.md +2 -2
- package/skills/arc/references/multi-tenancy.md +11 -2
- package/skills/arc/references/production.md +10 -9
- package/skills/arc/references/scim.md +247 -0
- package/skills/arc/references/testing.md +1 -1
- package/skills/arc-code-review/SKILL.md +141 -0
- package/skills/arc-code-review/references/anti-patterns.md +911 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
- package/skills/arc-code-review/references/migration-recipes.md +700 -0
- package/skills/arc-code-review/references/mongokit-migration.md +386 -0
- package/skills/arc-code-review/references/scaffolding.md +230 -0
- package/skills/arc-code-review/references/severity.md +127 -0
- package/dist/EventTransport-BFQjw9pB.mjs +0 -133
- package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-DUUiiimH.mjs +0 -964
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-CbcQRIch.mjs +0 -1054
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-Rg8axYPz.d.mts +0 -370
- /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
- /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
- /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
- /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
- /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
- /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
- /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
- /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
- /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
- /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
import fp from "fastify-plugin";
|
|
2
|
+
//#region src/scim/errors.ts
|
|
3
|
+
var ScimError = class extends Error {
|
|
4
|
+
statusCode;
|
|
5
|
+
scimType;
|
|
6
|
+
constructor(statusCode, scimType, detail) {
|
|
7
|
+
super(detail);
|
|
8
|
+
this.statusCode = statusCode;
|
|
9
|
+
this.scimType = scimType;
|
|
10
|
+
this.name = "ScimError";
|
|
11
|
+
}
|
|
12
|
+
toResponse() {
|
|
13
|
+
return {
|
|
14
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
15
|
+
status: String(this.statusCode),
|
|
16
|
+
...this.scimType ? { scimType: this.scimType } : {},
|
|
17
|
+
detail: this.message
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/scim/helpers.ts
|
|
23
|
+
/** Combine plugin defaults with the host's per-resource mapping override. */
|
|
24
|
+
function mergeMapping(defaults, override) {
|
|
25
|
+
if (!override) return defaults;
|
|
26
|
+
return {
|
|
27
|
+
schema: override.schema ?? defaults.schema,
|
|
28
|
+
attributes: {
|
|
29
|
+
...defaults.attributes,
|
|
30
|
+
...override.attributes ?? {}
|
|
31
|
+
},
|
|
32
|
+
reverseAttributes: override.reverseAttributes,
|
|
33
|
+
fromScim: override.fromScim ?? defaults.fromScim,
|
|
34
|
+
toScim: override.toScim ?? defaults.toScim
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the per-request auth check from `bearer` (static) or `verify` (callback).
|
|
39
|
+
* Throws at plugin construction if both / neither are configured.
|
|
40
|
+
*/
|
|
41
|
+
function makeAuthCheck(opts) {
|
|
42
|
+
if (opts.bearer && opts.verify) throw new Error("scimPlugin: pass either `bearer` or `verify`, not both");
|
|
43
|
+
if (opts.bearer) {
|
|
44
|
+
const expected = `Bearer ${opts.bearer}`;
|
|
45
|
+
return async (request) => {
|
|
46
|
+
if (request.headers.authorization !== expected) throw new ScimError(401, void 0, "Invalid bearer token");
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (opts.verify) {
|
|
50
|
+
const verify = opts.verify;
|
|
51
|
+
return async (request) => {
|
|
52
|
+
if (!await verify(request)) throw new ScimError(401, void 0, "SCIM authentication failed");
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
throw new Error("scimPlugin: configure either `bearer` (static token) or `verify` (callback)");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Format any thrown value into the canonical SCIM 2.0 error envelope and
|
|
59
|
+
* write it to the reply. Always sends `application/scim+json`.
|
|
60
|
+
*/
|
|
61
|
+
function sendScimError(reply, err) {
|
|
62
|
+
if (err instanceof ScimError) return reply.code(err.statusCode).header("Content-Type", "application/scim+json").send(err.toResponse());
|
|
63
|
+
const fallback = new ScimError(500, void 0, err instanceof Error ? err.message : "Internal SCIM error");
|
|
64
|
+
return reply.code(fallback.statusCode).header("Content-Type", "application/scim+json").send(fallback.toResponse());
|
|
65
|
+
}
|
|
66
|
+
function unwrapList(result) {
|
|
67
|
+
if (Array.isArray(result)) return {
|
|
68
|
+
items: result,
|
|
69
|
+
total: result.length
|
|
70
|
+
};
|
|
71
|
+
if (result && typeof result === "object") {
|
|
72
|
+
const r = result;
|
|
73
|
+
if (Array.isArray(r.data)) return {
|
|
74
|
+
items: r.data,
|
|
75
|
+
total: r.total ?? r.data.length
|
|
76
|
+
};
|
|
77
|
+
if (Array.isArray(r.docs)) return {
|
|
78
|
+
items: r.docs,
|
|
79
|
+
total: r.total ?? r.docs.length
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
items: [],
|
|
84
|
+
total: 0
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Coerce a Mongoose / Drizzle / plain doc to a plain `Record`. */
|
|
88
|
+
function asRecord(doc) {
|
|
89
|
+
if (!doc || typeof doc !== "object") return {};
|
|
90
|
+
if (typeof doc.toObject === "function") return doc.toObject();
|
|
91
|
+
return doc;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Register the `application/scim+json` Fastify content-type parser. Idempotent
|
|
95
|
+
* — second registration in the same scope is a no-op. Empty bodies (DELETE,
|
|
96
|
+
* GET) yield `undefined` rather than crashing on `JSON.parse("")`.
|
|
97
|
+
*/
|
|
98
|
+
function ensureScimContentTypeParser(fastify) {
|
|
99
|
+
if (fastify.hasContentTypeParser("application/scim+json")) return;
|
|
100
|
+
fastify.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_req, body, done) => {
|
|
101
|
+
const raw = body;
|
|
102
|
+
if (!raw || raw.length === 0) {
|
|
103
|
+
done(null, void 0);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
done(null, JSON.parse(raw));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
done(err, void 0);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Does the repo expose `findOneAndUpdate` (StandardRepo optional)? Required
|
|
115
|
+
* for SCIM PATCH because operator-shaped updates ($set / $unset / $push /
|
|
116
|
+
* $pull) need to flow through unchanged.
|
|
117
|
+
*/
|
|
118
|
+
function hasFindOneAndUpdate(repo) {
|
|
119
|
+
return typeof repo.findOneAndUpdate === "function";
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Does the repo expose `bulkWrite` with `replaceOne` support? Required for
|
|
123
|
+
* SCIM PUT — full document replacement is not in MinimalRepo, only reachable
|
|
124
|
+
* via `bulkWrite([{ replaceOne }])`.
|
|
125
|
+
*/
|
|
126
|
+
function hasBulkWrite(repo) {
|
|
127
|
+
return typeof repo.bulkWrite === "function";
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/scim/discovery.ts
|
|
131
|
+
function mountDiscoveryRoutes(fastify, prefix, hasGroups, authCheck, maxResults, observe) {
|
|
132
|
+
fastify.get(`${prefix}/ServiceProviderConfig`, async (request, reply) => {
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
try {
|
|
135
|
+
await authCheck(request);
|
|
136
|
+
const baseUrl = `${request.protocol}://${request.hostname}${prefix}`;
|
|
137
|
+
const payload = {
|
|
138
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
139
|
+
documentationUri: "https://datatracker.ietf.org/doc/html/rfc7644",
|
|
140
|
+
patch: { supported: true },
|
|
141
|
+
bulk: {
|
|
142
|
+
supported: false,
|
|
143
|
+
maxOperations: 0,
|
|
144
|
+
maxPayloadSize: 0
|
|
145
|
+
},
|
|
146
|
+
filter: {
|
|
147
|
+
supported: true,
|
|
148
|
+
maxResults
|
|
149
|
+
},
|
|
150
|
+
changePassword: { supported: false },
|
|
151
|
+
sort: { supported: true },
|
|
152
|
+
etag: { supported: false },
|
|
153
|
+
authenticationSchemes: [{
|
|
154
|
+
type: "oauthbearertoken",
|
|
155
|
+
name: "OAuth Bearer Token",
|
|
156
|
+
description: "Authentication via OAuth 2.0 bearer token",
|
|
157
|
+
specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
|
|
158
|
+
primary: true
|
|
159
|
+
}],
|
|
160
|
+
meta: {
|
|
161
|
+
location: `${baseUrl}/ServiceProviderConfig`,
|
|
162
|
+
resourceType: "ServiceProviderConfig"
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
observe({
|
|
166
|
+
resourceType: "discovery",
|
|
167
|
+
op: "discovery.serviceProviderConfig",
|
|
168
|
+
status: 200,
|
|
169
|
+
durationMs: Date.now() - start,
|
|
170
|
+
path: "/ServiceProviderConfig"
|
|
171
|
+
});
|
|
172
|
+
return reply.code(200).header("Content-Type", "application/scim+json").send(payload);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
observe({
|
|
175
|
+
resourceType: "discovery",
|
|
176
|
+
op: "discovery.serviceProviderConfig",
|
|
177
|
+
status: 401,
|
|
178
|
+
durationMs: Date.now() - start,
|
|
179
|
+
path: "/ServiceProviderConfig"
|
|
180
|
+
});
|
|
181
|
+
return sendScimError(reply, err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
fastify.get(`${prefix}/ResourceTypes`, async (request, reply) => {
|
|
185
|
+
const start = Date.now();
|
|
186
|
+
try {
|
|
187
|
+
await authCheck(request);
|
|
188
|
+
const baseUrl = `${request.protocol}://${request.hostname}${prefix}`;
|
|
189
|
+
const types = [{
|
|
190
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
191
|
+
id: "User",
|
|
192
|
+
name: "User",
|
|
193
|
+
endpoint: "/Users",
|
|
194
|
+
schema: "urn:ietf:params:scim:schemas:core:2.0:User",
|
|
195
|
+
meta: {
|
|
196
|
+
location: `${baseUrl}/ResourceTypes/User`,
|
|
197
|
+
resourceType: "ResourceType"
|
|
198
|
+
}
|
|
199
|
+
}];
|
|
200
|
+
if (hasGroups) types.push({
|
|
201
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
202
|
+
id: "Group",
|
|
203
|
+
name: "Group",
|
|
204
|
+
endpoint: "/Groups",
|
|
205
|
+
schema: "urn:ietf:params:scim:schemas:core:2.0:Group",
|
|
206
|
+
meta: {
|
|
207
|
+
location: `${baseUrl}/ResourceTypes/Group`,
|
|
208
|
+
resourceType: "ResourceType"
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
observe({
|
|
212
|
+
resourceType: "discovery",
|
|
213
|
+
op: "discovery.resourceTypes",
|
|
214
|
+
status: 200,
|
|
215
|
+
durationMs: Date.now() - start,
|
|
216
|
+
path: "/ResourceTypes"
|
|
217
|
+
});
|
|
218
|
+
return reply.code(200).header("Content-Type", "application/scim+json").send({
|
|
219
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
220
|
+
totalResults: types.length,
|
|
221
|
+
Resources: types
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
observe({
|
|
225
|
+
resourceType: "discovery",
|
|
226
|
+
op: "discovery.resourceTypes",
|
|
227
|
+
status: 401,
|
|
228
|
+
durationMs: Date.now() - start,
|
|
229
|
+
path: "/ResourceTypes"
|
|
230
|
+
});
|
|
231
|
+
return sendScimError(reply, err);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
fastify.get(`${prefix}/Schemas`, async (request, reply) => {
|
|
235
|
+
const start = Date.now();
|
|
236
|
+
try {
|
|
237
|
+
await authCheck(request);
|
|
238
|
+
observe({
|
|
239
|
+
resourceType: "discovery",
|
|
240
|
+
op: "discovery.schemas",
|
|
241
|
+
status: 200,
|
|
242
|
+
durationMs: Date.now() - start,
|
|
243
|
+
path: "/Schemas"
|
|
244
|
+
});
|
|
245
|
+
return reply.code(200).header("Content-Type", "application/scim+json").send({
|
|
246
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
247
|
+
totalResults: hasGroups ? 2 : 1,
|
|
248
|
+
Resources: [{
|
|
249
|
+
id: "urn:ietf:params:scim:schemas:core:2.0:User",
|
|
250
|
+
name: "User"
|
|
251
|
+
}, ...hasGroups ? [{
|
|
252
|
+
id: "urn:ietf:params:scim:schemas:core:2.0:Group",
|
|
253
|
+
name: "Group"
|
|
254
|
+
}] : []]
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
observe({
|
|
258
|
+
resourceType: "discovery",
|
|
259
|
+
op: "discovery.schemas",
|
|
260
|
+
status: 401,
|
|
261
|
+
durationMs: Date.now() - start,
|
|
262
|
+
path: "/Schemas"
|
|
263
|
+
});
|
|
264
|
+
return sendScimError(reply, err);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/scim/mapping.ts
|
|
270
|
+
const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
271
|
+
const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
|
272
|
+
const SCIM_ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
|
|
273
|
+
const DEFAULT_USER_MAPPING = {
|
|
274
|
+
schema: SCIM_USER_SCHEMA,
|
|
275
|
+
attributes: {
|
|
276
|
+
id: "id",
|
|
277
|
+
userName: "email",
|
|
278
|
+
"name.formatted": "name",
|
|
279
|
+
displayName: "name",
|
|
280
|
+
"emails.value": "email",
|
|
281
|
+
active: "isActive",
|
|
282
|
+
externalId: "externalId",
|
|
283
|
+
"meta.created": "createdAt",
|
|
284
|
+
"meta.lastModified": "updatedAt"
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const DEFAULT_GROUP_MAPPING = {
|
|
288
|
+
schema: SCIM_GROUP_SCHEMA,
|
|
289
|
+
attributes: {
|
|
290
|
+
id: "id",
|
|
291
|
+
displayName: "name",
|
|
292
|
+
externalId: "externalId",
|
|
293
|
+
"meta.created": "createdAt",
|
|
294
|
+
"meta.lastModified": "updatedAt"
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
function buildReverseMap(forward) {
|
|
298
|
+
const out = {};
|
|
299
|
+
for (const [scim, backend] of Object.entries(forward)) if (!(backend in out)) out[backend] = scim;
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
function getDeep(obj, path) {
|
|
303
|
+
const parts = path.split(".");
|
|
304
|
+
let cur = obj;
|
|
305
|
+
for (const p of parts) if (cur && typeof cur === "object") cur = cur[p];
|
|
306
|
+
else return void 0;
|
|
307
|
+
return cur;
|
|
308
|
+
}
|
|
309
|
+
function setDeep(obj, path, value) {
|
|
310
|
+
const parts = path.split(".");
|
|
311
|
+
let cur = obj;
|
|
312
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
313
|
+
const k = parts[i];
|
|
314
|
+
if (typeof cur[k] !== "object" || cur[k] === null) cur[k] = {};
|
|
315
|
+
cur = cur[k];
|
|
316
|
+
}
|
|
317
|
+
cur[parts[parts.length - 1]] = value;
|
|
318
|
+
}
|
|
319
|
+
/** Translate inbound SCIM JSON → resource shape. */
|
|
320
|
+
function scimToResource(scim, mapping) {
|
|
321
|
+
const mapped = {};
|
|
322
|
+
for (const [scimAttr, backendField] of Object.entries(mapping.attributes)) {
|
|
323
|
+
const v = getDeep(scim, scimAttr);
|
|
324
|
+
if (v !== void 0 && v !== null && v !== "") mapped[backendField] = v;
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(scim.emails)) {
|
|
327
|
+
const list = scim.emails;
|
|
328
|
+
const primary = list.find((e) => e.primary === true) ?? list[0];
|
|
329
|
+
if (primary?.value && mapping.attributes["emails.value"]) mapped[mapping.attributes["emails.value"]] = primary.value;
|
|
330
|
+
}
|
|
331
|
+
return mapping.fromScim ? mapping.fromScim(scim, mapped) : mapped;
|
|
332
|
+
}
|
|
333
|
+
/** Translate resource → SCIM JSON. */
|
|
334
|
+
function resourceToScim(resource, mapping, baseUrl) {
|
|
335
|
+
const reverse = mapping.reverseAttributes ?? buildReverseMap(mapping.attributes);
|
|
336
|
+
const out = { schemas: [mapping.schema] };
|
|
337
|
+
for (const [backendField, value] of Object.entries(resource)) {
|
|
338
|
+
if (value === void 0 || value === null) continue;
|
|
339
|
+
const scimAttr = reverse[backendField];
|
|
340
|
+
if (!scimAttr) continue;
|
|
341
|
+
setDeep(out, scimAttr, value);
|
|
342
|
+
}
|
|
343
|
+
const primaryEmail = resource[mapping.attributes["emails.value"] ?? "email"];
|
|
344
|
+
if (primaryEmail) out.emails = [{
|
|
345
|
+
value: primaryEmail,
|
|
346
|
+
primary: true,
|
|
347
|
+
type: "work"
|
|
348
|
+
}];
|
|
349
|
+
const id = resource.id ?? resource._id;
|
|
350
|
+
if (id) {
|
|
351
|
+
const resourceType = mapping.schema.endsWith("User") ? "User" : "Group";
|
|
352
|
+
const meta = out.meta ?? {};
|
|
353
|
+
meta.resourceType = resourceType;
|
|
354
|
+
meta.location = `${baseUrl}/${resourceType}s/${id}`;
|
|
355
|
+
out.meta = meta;
|
|
356
|
+
}
|
|
357
|
+
return mapping.toScim ? mapping.toScim(resource, out) : out;
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/scim/filter.ts
|
|
361
|
+
/**
|
|
362
|
+
* SCIM 2.0 filter language parser (RFC 7644 §3.4.2.2)
|
|
363
|
+
*
|
|
364
|
+
* Translates SCIM filter expressions into arc's query DSL so existing
|
|
365
|
+
* resources can serve `/scim/v2/Users?filter=...` without per-resource glue.
|
|
366
|
+
*
|
|
367
|
+
* Supports the subset every IdP actually emits in production:
|
|
368
|
+
* - Comparison ops: eq, ne, co, sw, ew, gt, ge, lt, le, pr (present)
|
|
369
|
+
* - Logical: and, or, not
|
|
370
|
+
* - Grouping: ( )
|
|
371
|
+
* - Attribute paths: `userName`, `name.familyName`, `emails[type eq "work"].value`
|
|
372
|
+
*
|
|
373
|
+
* Out of scope (yields a 400 with a clear reason):
|
|
374
|
+
* - Complex value paths beyond one level
|
|
375
|
+
* - Sub-attribute traversal in operands
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* parseScimFilter('userName eq "alice@acme.com"')
|
|
379
|
+
* → { userName: 'alice@acme.com' }
|
|
380
|
+
*
|
|
381
|
+
* parseScimFilter('active eq true and name.familyName sw "S"')
|
|
382
|
+
* → { $and: [{ active: true }, { 'name.familyName': { $regex: '^S' } }] }
|
|
383
|
+
*/
|
|
384
|
+
const COMPARISON_OPS = new Set([
|
|
385
|
+
"eq",
|
|
386
|
+
"ne",
|
|
387
|
+
"co",
|
|
388
|
+
"sw",
|
|
389
|
+
"ew",
|
|
390
|
+
"gt",
|
|
391
|
+
"ge",
|
|
392
|
+
"lt",
|
|
393
|
+
"le",
|
|
394
|
+
"pr"
|
|
395
|
+
]);
|
|
396
|
+
const LOGICAL_OPS = new Set([
|
|
397
|
+
"and",
|
|
398
|
+
"or",
|
|
399
|
+
"not"
|
|
400
|
+
]);
|
|
401
|
+
function tokenize(input) {
|
|
402
|
+
const tokens = [];
|
|
403
|
+
let i = 0;
|
|
404
|
+
while (i < input.length) {
|
|
405
|
+
const c = input[i] ?? "";
|
|
406
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
407
|
+
i++;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (c === "(") {
|
|
411
|
+
tokens.push({ kind: "lparen" });
|
|
412
|
+
i++;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (c === ")") {
|
|
416
|
+
tokens.push({ kind: "rparen" });
|
|
417
|
+
i++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (c === "[") {
|
|
421
|
+
tokens.push({ kind: "lbracket" });
|
|
422
|
+
i++;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (c === "]") {
|
|
426
|
+
tokens.push({ kind: "rbracket" });
|
|
427
|
+
i++;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (c === "\"") {
|
|
431
|
+
let j = i + 1;
|
|
432
|
+
let value = "";
|
|
433
|
+
while (j < input.length && input[j] !== "\"") if (input[j] === "\\" && j + 1 < input.length) {
|
|
434
|
+
value += input[j + 1];
|
|
435
|
+
j += 2;
|
|
436
|
+
} else {
|
|
437
|
+
value += input[j];
|
|
438
|
+
j++;
|
|
439
|
+
}
|
|
440
|
+
if (j >= input.length) throw new ScimError(400, "invalidFilter", "Unterminated string literal");
|
|
441
|
+
tokens.push({
|
|
442
|
+
kind: "string",
|
|
443
|
+
value
|
|
444
|
+
});
|
|
445
|
+
i = j + 1;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const next = input[i + 1] ?? "";
|
|
449
|
+
if (c >= "0" && c <= "9" || c === "-" && next >= "0" && next <= "9") {
|
|
450
|
+
let j = i;
|
|
451
|
+
if (input[j] === "-") j++;
|
|
452
|
+
while (j < input.length) {
|
|
453
|
+
const cj = input[j] ?? "";
|
|
454
|
+
if (!(cj >= "0" && cj <= "9" || cj === ".")) break;
|
|
455
|
+
j++;
|
|
456
|
+
}
|
|
457
|
+
const num = Number(input.slice(i, j));
|
|
458
|
+
if (Number.isNaN(num)) throw new ScimError(400, "invalidFilter", `Invalid number near "${input.slice(i, j)}"`);
|
|
459
|
+
tokens.push({
|
|
460
|
+
kind: "number",
|
|
461
|
+
value: num
|
|
462
|
+
});
|
|
463
|
+
i = j;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (/[a-zA-Z_]/.test(c ?? "")) {
|
|
467
|
+
let j = i;
|
|
468
|
+
while (j < input.length && /[a-zA-Z0-9_.$:-]/.test(input[j] ?? "")) j++;
|
|
469
|
+
const word = input.slice(i, j);
|
|
470
|
+
const lower = word.toLowerCase();
|
|
471
|
+
if (lower === "true" || lower === "false") tokens.push({
|
|
472
|
+
kind: "bool",
|
|
473
|
+
value: lower === "true"
|
|
474
|
+
});
|
|
475
|
+
else if (lower === "null") tokens.push({ kind: "null" });
|
|
476
|
+
else if (COMPARISON_OPS.has(lower) || LOGICAL_OPS.has(lower)) tokens.push({
|
|
477
|
+
kind: "op",
|
|
478
|
+
value: lower
|
|
479
|
+
});
|
|
480
|
+
else tokens.push({
|
|
481
|
+
kind: "ident",
|
|
482
|
+
value: word
|
|
483
|
+
});
|
|
484
|
+
i = j;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
throw new ScimError(400, "invalidFilter", `Unexpected character "${c}" at position ${i}`);
|
|
488
|
+
}
|
|
489
|
+
return tokens;
|
|
490
|
+
}
|
|
491
|
+
var Parser = class {
|
|
492
|
+
pos = 0;
|
|
493
|
+
tokens;
|
|
494
|
+
constructor(tokens) {
|
|
495
|
+
this.tokens = tokens;
|
|
496
|
+
}
|
|
497
|
+
parse() {
|
|
498
|
+
const node = this.parseOr();
|
|
499
|
+
if (this.pos < this.tokens.length) throw new ScimError(400, "invalidFilter", `Unexpected token at end of filter`);
|
|
500
|
+
return node;
|
|
501
|
+
}
|
|
502
|
+
peek() {
|
|
503
|
+
return this.tokens[this.pos];
|
|
504
|
+
}
|
|
505
|
+
consume() {
|
|
506
|
+
const t = this.tokens[this.pos++];
|
|
507
|
+
if (!t) throw new ScimError(400, "invalidFilter", "Unexpected end of filter");
|
|
508
|
+
return t;
|
|
509
|
+
}
|
|
510
|
+
parseOr() {
|
|
511
|
+
let left = this.parseAnd();
|
|
512
|
+
while (this.peek()?.kind === "op" && this.peek().value === "or") {
|
|
513
|
+
this.consume();
|
|
514
|
+
left = {
|
|
515
|
+
kind: "or",
|
|
516
|
+
left,
|
|
517
|
+
right: this.parseAnd()
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return left;
|
|
521
|
+
}
|
|
522
|
+
parseAnd() {
|
|
523
|
+
let left = this.parseNot();
|
|
524
|
+
while (this.peek()?.kind === "op" && this.peek().value === "and") {
|
|
525
|
+
this.consume();
|
|
526
|
+
left = {
|
|
527
|
+
kind: "and",
|
|
528
|
+
left,
|
|
529
|
+
right: this.parseNot()
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
return left;
|
|
533
|
+
}
|
|
534
|
+
parseNot() {
|
|
535
|
+
if (this.peek()?.kind === "op" && this.peek().value === "not") {
|
|
536
|
+
this.consume();
|
|
537
|
+
const next = this.peek();
|
|
538
|
+
if (!next || next.kind !== "lparen") throw new ScimError(400, "invalidFilter", "Expected '(' after 'not'");
|
|
539
|
+
this.consume();
|
|
540
|
+
const child = this.parseOr();
|
|
541
|
+
if (this.consume().kind !== "rparen") throw new ScimError(400, "invalidFilter", "Expected ')' after 'not(...)'");
|
|
542
|
+
return {
|
|
543
|
+
kind: "not",
|
|
544
|
+
child
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return this.parsePrimary();
|
|
548
|
+
}
|
|
549
|
+
parsePrimary() {
|
|
550
|
+
const t = this.peek();
|
|
551
|
+
if (!t) throw new ScimError(400, "invalidFilter", "Unexpected end of filter");
|
|
552
|
+
if (t.kind === "lparen") {
|
|
553
|
+
this.consume();
|
|
554
|
+
const inner = this.parseOr();
|
|
555
|
+
if (this.consume().kind !== "rparen") throw new ScimError(400, "invalidFilter", "Expected ')' after grouped expression");
|
|
556
|
+
return inner;
|
|
557
|
+
}
|
|
558
|
+
return this.parseComparison();
|
|
559
|
+
}
|
|
560
|
+
parseComparison() {
|
|
561
|
+
const attrTok = this.consume();
|
|
562
|
+
if (attrTok.kind !== "ident") throw new ScimError(400, "invalidFilter", "Expected attribute path");
|
|
563
|
+
let attr = attrTok.value;
|
|
564
|
+
if (this.peek()?.kind === "lbracket") {
|
|
565
|
+
this.consume();
|
|
566
|
+
let depth = 1;
|
|
567
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
568
|
+
const inner = this.consume();
|
|
569
|
+
if (inner.kind === "lbracket") depth++;
|
|
570
|
+
if (inner.kind === "rbracket") depth--;
|
|
571
|
+
}
|
|
572
|
+
const next = this.peek();
|
|
573
|
+
if (next?.kind === "ident" && next.value.startsWith(".")) {
|
|
574
|
+
this.consume();
|
|
575
|
+
attr += next.value;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const opTok = this.consume();
|
|
579
|
+
if (opTok.kind !== "op" || !COMPARISON_OPS.has(opTok.value)) throw new ScimError(400, "invalidFilter", `Expected comparison operator after "${attr}"`);
|
|
580
|
+
if (opTok.value === "pr") return {
|
|
581
|
+
kind: "present",
|
|
582
|
+
attr
|
|
583
|
+
};
|
|
584
|
+
const valTok = this.consume();
|
|
585
|
+
let value;
|
|
586
|
+
if (valTok.kind === "string") value = valTok.value;
|
|
587
|
+
else if (valTok.kind === "number") value = valTok.value;
|
|
588
|
+
else if (valTok.kind === "bool") value = valTok.value;
|
|
589
|
+
else if (valTok.kind === "null") value = null;
|
|
590
|
+
else throw new ScimError(400, "invalidFilter", `Expected literal after "${opTok.value}"`);
|
|
591
|
+
return {
|
|
592
|
+
kind: "compare",
|
|
593
|
+
attr,
|
|
594
|
+
op: opTok.value,
|
|
595
|
+
value
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
function escapeRegex(s) {
|
|
600
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
601
|
+
}
|
|
602
|
+
function nodeToQuery(node, mapAttr) {
|
|
603
|
+
switch (node.kind) {
|
|
604
|
+
case "and": return { $and: [nodeToQuery(node.left, mapAttr), nodeToQuery(node.right, mapAttr)] };
|
|
605
|
+
case "or": return { $or: [nodeToQuery(node.left, mapAttr), nodeToQuery(node.right, mapAttr)] };
|
|
606
|
+
case "not": return { $nor: [nodeToQuery(node.child, mapAttr)] };
|
|
607
|
+
case "present": {
|
|
608
|
+
const field = mapAttr(node.attr);
|
|
609
|
+
if (!field) throw new ScimError(400, "invalidFilter", `Attribute "${node.attr}" is not filterable`);
|
|
610
|
+
return { [field]: {
|
|
611
|
+
$exists: true,
|
|
612
|
+
$ne: null
|
|
613
|
+
} };
|
|
614
|
+
}
|
|
615
|
+
case "compare": {
|
|
616
|
+
const field = mapAttr(node.attr);
|
|
617
|
+
if (!field) throw new ScimError(400, "invalidFilter", `Attribute "${node.attr}" is not filterable`);
|
|
618
|
+
switch (node.op) {
|
|
619
|
+
case "eq": return { [field]: node.value };
|
|
620
|
+
case "ne": return { [field]: { $ne: node.value } };
|
|
621
|
+
case "co":
|
|
622
|
+
if (typeof node.value !== "string") throw new ScimError(400, "invalidFilter", "'co' requires a string operand");
|
|
623
|
+
return { [field]: {
|
|
624
|
+
$regex: escapeRegex(node.value),
|
|
625
|
+
$options: "i"
|
|
626
|
+
} };
|
|
627
|
+
case "sw":
|
|
628
|
+
if (typeof node.value !== "string") throw new ScimError(400, "invalidFilter", "'sw' requires a string operand");
|
|
629
|
+
return { [field]: {
|
|
630
|
+
$regex: `^${escapeRegex(node.value)}`,
|
|
631
|
+
$options: "i"
|
|
632
|
+
} };
|
|
633
|
+
case "ew":
|
|
634
|
+
if (typeof node.value !== "string") throw new ScimError(400, "invalidFilter", "'ew' requires a string operand");
|
|
635
|
+
return { [field]: {
|
|
636
|
+
$regex: `${escapeRegex(node.value)}$`,
|
|
637
|
+
$options: "i"
|
|
638
|
+
} };
|
|
639
|
+
case "gt": return { [field]: { $gt: node.value } };
|
|
640
|
+
case "ge": return { [field]: { $gte: node.value } };
|
|
641
|
+
case "lt": return { [field]: { $lt: node.value } };
|
|
642
|
+
case "le": return { [field]: { $lte: node.value } };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Parse a SCIM 2.0 filter expression and translate to arc/Mongo query shape.
|
|
649
|
+
*
|
|
650
|
+
* @param filter Raw filter string from `?filter=...`
|
|
651
|
+
* @param mapAttr Mapping function: SCIM attr → backend field name. Return
|
|
652
|
+
* `undefined` to deny (yields 400 invalidFilter). Use `IDENTITY_MAP` when
|
|
653
|
+
* the resource exposes SCIM-named attributes directly.
|
|
654
|
+
*/
|
|
655
|
+
function parseScimFilter(filter, mapAttr) {
|
|
656
|
+
if (!filter || filter.trim().length === 0) return {};
|
|
657
|
+
return nodeToQuery(new Parser(tokenize(filter)).parse(), mapAttr);
|
|
658
|
+
}
|
|
659
|
+
/** Pass-through mapper for resources that already use SCIM attribute names. */
|
|
660
|
+
const IDENTITY_MAP = (a) => a;
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/scim/patch.ts
|
|
663
|
+
/**
|
|
664
|
+
* SCIM 2.0 PATCH parser (RFC 7644 §3.5.2)
|
|
665
|
+
*
|
|
666
|
+
* Translates SCIM PATCH operations into a flat update object the resource's
|
|
667
|
+
* existing PATCH handler can apply. Supports the three operations every IdP
|
|
668
|
+
* actually emits: `add`, `replace`, `remove`.
|
|
669
|
+
*
|
|
670
|
+
* **Path support**:
|
|
671
|
+
* - Simple attribute: `userName` → `{ userName: <value> }`
|
|
672
|
+
* - Sub-attribute: `name.familyName` → `{ 'name.familyName': <value> }`
|
|
673
|
+
* - No path (op-level value): `replace` with object value → spread into update
|
|
674
|
+
* - Multi-value with filter: `emails[type eq "work"].value` — parsed but
|
|
675
|
+
* translated to a `$set` on the matching array element by index lookup
|
|
676
|
+
* (host resolves index via the supplied `lookupArrayIndex` callback)
|
|
677
|
+
*
|
|
678
|
+
* @example
|
|
679
|
+
* parseScimPatch({
|
|
680
|
+
* schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
|
|
681
|
+
* Operations: [
|
|
682
|
+
* { op: 'replace', path: 'displayName', value: 'Alice S.' },
|
|
683
|
+
* { op: 'add', path: 'emails', value: [{ type: 'work', value: 'a@x.com' }] },
|
|
684
|
+
* { op: 'remove', path: 'emails[type eq "old"]' },
|
|
685
|
+
* ],
|
|
686
|
+
* })
|
|
687
|
+
* → { $set: { displayName: 'Alice S.' }, $push: { emails: ... }, $pull: { emails: ... } }
|
|
688
|
+
*/
|
|
689
|
+
function parseScimPatch(req) {
|
|
690
|
+
const ops = req.Operations ?? req.operations ?? [];
|
|
691
|
+
if (ops.length === 0) throw new ScimError(400, "invalidSyntax", "PATCH request must include at least one operation");
|
|
692
|
+
const out = {
|
|
693
|
+
$set: {},
|
|
694
|
+
$push: {},
|
|
695
|
+
$pull: {},
|
|
696
|
+
$unset: {}
|
|
697
|
+
};
|
|
698
|
+
for (const op of ops) {
|
|
699
|
+
const verb = (op.op ?? "").toLowerCase();
|
|
700
|
+
if (verb !== "add" && verb !== "replace" && verb !== "remove") throw new ScimError(400, "invalidSyntax", `Unsupported PATCH op "${op.op}" (allowed: add, replace, remove)`);
|
|
701
|
+
if (!op.path) {
|
|
702
|
+
if (verb === "remove") throw new ScimError(400, "noTarget", "remove operation requires a path");
|
|
703
|
+
if (op.value === void 0 || op.value === null || typeof op.value !== "object") throw new ScimError(400, "invalidValue", "Path-less add/replace must carry an object value");
|
|
704
|
+
Object.assign(out.$set, op.value);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const bracketIdx = op.path.indexOf("[");
|
|
708
|
+
if (bracketIdx >= 0) {
|
|
709
|
+
const closeBracket = op.path.indexOf("]", bracketIdx);
|
|
710
|
+
if (closeBracket < 0) throw new ScimError(400, "invalidPath", `Unterminated bracket in path "${op.path}"`);
|
|
711
|
+
const arrayField = op.path.slice(0, bracketIdx);
|
|
712
|
+
if (verb === "remove") out.$pull[arrayField] = { __scimFilter: op.path.slice(bracketIdx + 1, closeBracket) };
|
|
713
|
+
else if (verb === "add") out.$push[arrayField] = op.value;
|
|
714
|
+
else {
|
|
715
|
+
out.$pull[arrayField] = { __scimFilter: op.path.slice(bracketIdx + 1, closeBracket) };
|
|
716
|
+
out.$push[arrayField] = op.value;
|
|
717
|
+
}
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (verb === "remove") {
|
|
721
|
+
out.$unset[op.path] = true;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (verb === "add" && Array.isArray(op.value)) {
|
|
725
|
+
out.$push[op.path] = { $each: op.value };
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
out.$set[op.path] = op.value;
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Flatten a {@link ScimUpdate} into a plain `{ field: value }` object suitable
|
|
734
|
+
* for arc resource `PATCH` handlers that expect a partial document. Drops
|
|
735
|
+
* `$push` / `$pull` / `$unset` semantics — use {@link parseScimPatch} directly
|
|
736
|
+
* when the host needs the full op stream (e.g. to issue array mutations).
|
|
737
|
+
*/
|
|
738
|
+
function scimUpdateToFlatPatch(update) {
|
|
739
|
+
const out = { ...update.$set };
|
|
740
|
+
for (const k of Object.keys(update.$unset)) out[k] = null;
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/scim/routes.ts
|
|
745
|
+
/**
|
|
746
|
+
* Wrap a route body so every outcome (success, ScimError, unknown error)
|
|
747
|
+
* funnels through one observability path. Generic over the request type so
|
|
748
|
+
* Fastify's route-shape generics (`<{ Params, Body, Querystring }>`) narrow
|
|
749
|
+
* `request.params` / `request.body` natively — no `as FastifyRequest & {...}`
|
|
750
|
+
* casts at call sites.
|
|
751
|
+
*/
|
|
752
|
+
function withObserve(fastify, observe, resourceType, op, path, body) {
|
|
753
|
+
return async (request, reply) => {
|
|
754
|
+
const start = Date.now();
|
|
755
|
+
try {
|
|
756
|
+
const result = await body(request);
|
|
757
|
+
reply.code(result.status).header("Content-Type", "application/scim+json");
|
|
758
|
+
if (result.headers) for (const [k, v] of Object.entries(result.headers)) reply.header(k, v);
|
|
759
|
+
observe({
|
|
760
|
+
resourceType,
|
|
761
|
+
op,
|
|
762
|
+
status: result.status,
|
|
763
|
+
durationMs: Date.now() - start,
|
|
764
|
+
path
|
|
765
|
+
});
|
|
766
|
+
return result.payload === void 0 ? reply.send() : reply.send(result.payload);
|
|
767
|
+
} catch (err) {
|
|
768
|
+
const scim = err instanceof ScimError ? err : null;
|
|
769
|
+
observe({
|
|
770
|
+
resourceType,
|
|
771
|
+
op,
|
|
772
|
+
status: scim?.statusCode ?? 500,
|
|
773
|
+
durationMs: Date.now() - start,
|
|
774
|
+
scimType: scim?.scimType,
|
|
775
|
+
path
|
|
776
|
+
});
|
|
777
|
+
fastify.log?.warn?.({
|
|
778
|
+
err,
|
|
779
|
+
resourceType,
|
|
780
|
+
op,
|
|
781
|
+
path
|
|
782
|
+
}, "SCIM request failed");
|
|
783
|
+
return sendScimError(reply, err);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Build a backend-shaped patch from SCIM ops, mapping SCIM attribute names
|
|
789
|
+
* onto backend field names per the resource's mapping. Returns canonical
|
|
790
|
+
* Mongo-style operators that flow through `findOneAndUpdate` unchanged.
|
|
791
|
+
*
|
|
792
|
+
* Returns `null` when the SCIM body parses but contains nothing the kit can
|
|
793
|
+
* apply through the canonical contract — caller surfaces 400 in that case.
|
|
794
|
+
*/
|
|
795
|
+
function scimOpsToBackendOps(scimOps, attrMap) {
|
|
796
|
+
const $set = {};
|
|
797
|
+
const $unset = {};
|
|
798
|
+
const $push = {};
|
|
799
|
+
const $pull = {};
|
|
800
|
+
const map = (scimAttr) => attrMap[scimAttr] ?? scimAttr;
|
|
801
|
+
for (const [scimAttr, value] of Object.entries(scimOps.$set)) $set[map(scimAttr)] = value;
|
|
802
|
+
for (const scimAttr of Object.keys(scimOps.$unset)) $unset[map(scimAttr)] = true;
|
|
803
|
+
for (const [scimAttr, value] of Object.entries(scimOps.$push)) $push[map(scimAttr)] = value;
|
|
804
|
+
for (const [scimAttr, value] of Object.entries(scimOps.$pull)) $pull[map(scimAttr)] = value;
|
|
805
|
+
const ops = {};
|
|
806
|
+
if (Object.keys($set).length > 0) ops.$set = $set;
|
|
807
|
+
if (Object.keys($unset).length > 0) ops.$unset = $unset;
|
|
808
|
+
if (Object.keys($push).length > 0) ops.$push = $push;
|
|
809
|
+
if (Object.keys($pull).length > 0) ops.$pull = $pull;
|
|
810
|
+
return {
|
|
811
|
+
ops,
|
|
812
|
+
hasArrayOps: Object.keys($push).length > 0 || Object.keys($pull).length > 0,
|
|
813
|
+
arrayOpFields: [...Object.keys($push), ...Object.keys($pull)]
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/** Pluck the id field a kit wrote on the document, accepting `id` or `_id`. */
|
|
817
|
+
function extractId(doc) {
|
|
818
|
+
const raw = doc.id ?? doc._id;
|
|
819
|
+
return raw == null ? "" : String(raw);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Mount the canonical SCIM CRUD surface for one resource type (Users /
|
|
823
|
+
* Groups) under the plugin's prefix. Authentication is enforced inside
|
|
824
|
+
* each handler so the observability span captures auth failures too.
|
|
825
|
+
*/
|
|
826
|
+
function mountResourceRoutes(fastify, resourceTypeName, mounted, authCheck, maxResults, observe) {
|
|
827
|
+
const prefix = mounted.basePath;
|
|
828
|
+
const repo = mounted.binding.resource.adapter.repository;
|
|
829
|
+
const mapping = mounted.mapping;
|
|
830
|
+
const filterMapper = (attr) => mapping.attributes[attr] ?? attr;
|
|
831
|
+
const toScim = (doc, request) => resourceToScim(asRecord(doc), mapping, `${request.protocol}://${request.hostname}${prefix}`);
|
|
832
|
+
const supportsOperators = hasFindOneAndUpdate(repo);
|
|
833
|
+
const supportsReplace = hasBulkWrite(repo);
|
|
834
|
+
const notFound = () => new ScimError(404, void 0, `${resourceTypeName.slice(0, -1)} not found`);
|
|
835
|
+
const applyPatch = supportsOperators ? async (id, p) => {
|
|
836
|
+
try {
|
|
837
|
+
return await repo.findOneAndUpdate({ id }, p.ops);
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (p.hasArrayOps) throw new ScimError(400, "invalidValue", `Array mutations ($push/$pull) on field(s) ${p.arrayOpFields.join(", ")} are not supported by this kit's repository. Use a kit with native array-column support (e.g. @classytic/mongokit), or replace the field wholesale via PUT. Underlying error: ${err instanceof Error ? err.message : String(err)}`);
|
|
840
|
+
throw err;
|
|
841
|
+
}
|
|
842
|
+
} : async (id, p, raw) => {
|
|
843
|
+
if (Object.keys(raw.$unset).length > 0 || p.hasArrayOps) throw new ScimError(400, "invalidValue", "This kit's repository does not implement findOneAndUpdate; only $set-shaped PATCH operations are supported. Drop $unset / $push / $pull from the request, or use a kit that exposes findOneAndUpdate.");
|
|
844
|
+
const setData = p.ops.$set ?? {};
|
|
845
|
+
if (Object.keys(setData).length === 0) return repo.getById(id);
|
|
846
|
+
return repo.update(id, setData);
|
|
847
|
+
};
|
|
848
|
+
fastify.get(`${prefix}/${resourceTypeName}`, withObserve(fastify, observe, resourceTypeName, "list", `/${resourceTypeName}`, async (request) => {
|
|
849
|
+
await authCheck(request);
|
|
850
|
+
const q = request.query;
|
|
851
|
+
const startIndex = Math.max(1, Number.parseInt(q.startIndex ?? "1", 10) || 1);
|
|
852
|
+
const count = Math.min(maxResults, Number.parseInt(q.count ?? "100", 10) || 100);
|
|
853
|
+
const filters = q.filter ? parseScimFilter(q.filter, filterMapper) : {};
|
|
854
|
+
const sort = q.sortBy ? { [filterMapper(q.sortBy)]: q.sortOrder === "descending" ? -1 : 1 } : void 0;
|
|
855
|
+
const { items, total } = unwrapList(await repo.getAll({
|
|
856
|
+
filters,
|
|
857
|
+
page: Math.floor((startIndex - 1) / count) + 1,
|
|
858
|
+
limit: count,
|
|
859
|
+
sort
|
|
860
|
+
}));
|
|
861
|
+
const resources = items.map((item) => toScim(item, request));
|
|
862
|
+
return {
|
|
863
|
+
status: 200,
|
|
864
|
+
payload: {
|
|
865
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
866
|
+
totalResults: total,
|
|
867
|
+
startIndex,
|
|
868
|
+
itemsPerPage: resources.length,
|
|
869
|
+
Resources: resources
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
}));
|
|
873
|
+
fastify.get(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "get", `/${resourceTypeName}/:id`, async (request) => {
|
|
874
|
+
await authCheck(request);
|
|
875
|
+
const doc = await repo.getById(request.params.id);
|
|
876
|
+
if (!doc) throw notFound();
|
|
877
|
+
return {
|
|
878
|
+
status: 200,
|
|
879
|
+
payload: toScim(doc, request)
|
|
880
|
+
};
|
|
881
|
+
}));
|
|
882
|
+
fastify.post(`${prefix}/${resourceTypeName}`, withObserve(fastify, observe, resourceTypeName, "create", `/${resourceTypeName}`, async (request) => {
|
|
883
|
+
await authCheck(request);
|
|
884
|
+
const data = scimToResource(request.body ?? {}, mapping);
|
|
885
|
+
const created = await repo.create(data);
|
|
886
|
+
const id = extractId(asRecord(created));
|
|
887
|
+
const baseUrl = `${request.protocol}://${request.hostname}${prefix}`;
|
|
888
|
+
return {
|
|
889
|
+
status: 201,
|
|
890
|
+
payload: toScim(created, request),
|
|
891
|
+
headers: { Location: `${baseUrl}/${resourceTypeName}/${id}` }
|
|
892
|
+
};
|
|
893
|
+
}));
|
|
894
|
+
fastify.put(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "replace", `/${resourceTypeName}/:id`, async (request) => {
|
|
895
|
+
await authCheck(request);
|
|
896
|
+
if (!supportsReplace) throw new ScimError(501, void 0, "Full replacement (PUT) requires the underlying repository to expose bulkWrite([{ replaceOne }]). This kit does not implement bulkWrite. Use PATCH to apply partial updates instead.");
|
|
897
|
+
const data = scimToResource(request.body ?? {}, mapping);
|
|
898
|
+
await repo.bulkWrite([{ replaceOne: {
|
|
899
|
+
filter: { id: request.params.id },
|
|
900
|
+
replacement: data
|
|
901
|
+
} }]);
|
|
902
|
+
const updated = await repo.getById(request.params.id);
|
|
903
|
+
if (!updated) throw notFound();
|
|
904
|
+
return {
|
|
905
|
+
status: 200,
|
|
906
|
+
payload: toScim(updated, request)
|
|
907
|
+
};
|
|
908
|
+
}));
|
|
909
|
+
fastify.patch(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "patch", `/${resourceTypeName}/:id`, async (request) => {
|
|
910
|
+
await authCheck(request);
|
|
911
|
+
const scimOps = parseScimPatch(request.body);
|
|
912
|
+
const patchOps = scimOpsToBackendOps(scimOps, mapping.attributes);
|
|
913
|
+
const updated = await applyPatch(request.params.id, patchOps, scimOps);
|
|
914
|
+
if (!updated) throw notFound();
|
|
915
|
+
return {
|
|
916
|
+
status: 200,
|
|
917
|
+
payload: toScim(updated, request)
|
|
918
|
+
};
|
|
919
|
+
}));
|
|
920
|
+
fastify.delete(`${prefix}/${resourceTypeName}/:id`, withObserve(fastify, observe, resourceTypeName, "delete", `/${resourceTypeName}/:id`, async (request) => {
|
|
921
|
+
await authCheck(request);
|
|
922
|
+
await repo.delete(request.params.id);
|
|
923
|
+
return { status: 204 };
|
|
924
|
+
}));
|
|
925
|
+
}
|
|
926
|
+
//#endregion
|
|
927
|
+
//#region src/scim/index.ts
|
|
928
|
+
const scimPlugin = async (fastify, opts) => {
|
|
929
|
+
if (!opts.users) throw new Error("scimPlugin: `users` binding is required");
|
|
930
|
+
const prefix = opts.prefix ?? "/scim/v2";
|
|
931
|
+
const maxResults = opts.maxResults ?? 200;
|
|
932
|
+
const authCheck = makeAuthCheck(opts);
|
|
933
|
+
const observe = opts.observe ?? ((event) => {
|
|
934
|
+
fastify.log?.info?.({ scim: event }, "scim.request");
|
|
935
|
+
});
|
|
936
|
+
ensureScimContentTypeParser(fastify);
|
|
937
|
+
mountResourceRoutes(fastify, "Users", {
|
|
938
|
+
binding: opts.users,
|
|
939
|
+
mapping: mergeMapping(DEFAULT_USER_MAPPING, opts.users.mapping),
|
|
940
|
+
basePath: prefix
|
|
941
|
+
}, authCheck, maxResults, observe);
|
|
942
|
+
let hasGroups = false;
|
|
943
|
+
if (opts.groups) {
|
|
944
|
+
hasGroups = true;
|
|
945
|
+
mountResourceRoutes(fastify, "Groups", {
|
|
946
|
+
binding: opts.groups,
|
|
947
|
+
mapping: mergeMapping(DEFAULT_GROUP_MAPPING, opts.groups.mapping),
|
|
948
|
+
basePath: prefix
|
|
949
|
+
}, authCheck, maxResults, observe);
|
|
950
|
+
}
|
|
951
|
+
mountDiscoveryRoutes(fastify, prefix, hasGroups, authCheck, maxResults, observe);
|
|
952
|
+
fastify.log?.debug?.({
|
|
953
|
+
prefix,
|
|
954
|
+
hasGroups,
|
|
955
|
+
auth: opts.bearer ? "bearer" : "verify"
|
|
956
|
+
}, "SCIM 2.0 plugin mounted");
|
|
957
|
+
};
|
|
958
|
+
var scim_default = fp(scimPlugin, {
|
|
959
|
+
name: "arc-scim",
|
|
960
|
+
fastify: "5.x"
|
|
961
|
+
});
|
|
962
|
+
//#endregion
|
|
963
|
+
export { DEFAULT_GROUP_MAPPING, DEFAULT_USER_MAPPING, IDENTITY_MAP, SCIM_ENTERPRISE_USER_SCHEMA, SCIM_GROUP_SCHEMA, SCIM_USER_SCHEMA, ScimError, scim_default as default, parseScimFilter, parseScimPatch, resourceToScim, scimPlugin, scimToResource, scimUpdateToFlatPatch };
|