@thotischner/observability-mcp 3.0.0 → 3.1.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.
- package/dist/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -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/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/policy/batch-dry-run.js +15 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +522 -67
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -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/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/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 +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- 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/index.d.ts +13 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- 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/types.d.ts +48 -0
- package/dist/ui/index.html +898 -116
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { applyPatchOps } from "./routes.js";
|
|
4
|
+
function group(members = []) {
|
|
5
|
+
return {
|
|
6
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
|
7
|
+
id: "g1",
|
|
8
|
+
displayName: "team",
|
|
9
|
+
members,
|
|
10
|
+
meta: { resourceType: "Group", created: "2026-06-06T00:00:00Z", lastModified: "2026-06-06T00:00:00Z" },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function patch(...ops) {
|
|
14
|
+
return { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: ops };
|
|
15
|
+
}
|
|
16
|
+
// --- replace (existing behaviour preserved) --------------------------
|
|
17
|
+
test("replace with no path merges allow-listed keys", () => {
|
|
18
|
+
const out = applyPatchOps(group(), patch({ op: "replace", value: { displayName: "renamed", externalId: "x" } }));
|
|
19
|
+
assert.equal(out.displayName, "renamed");
|
|
20
|
+
assert.equal(out.externalId, "x");
|
|
21
|
+
});
|
|
22
|
+
test("replace with path sets that attribute", () => {
|
|
23
|
+
const out = applyPatchOps(group(), patch({ op: "replace", path: "displayName", value: "n2" }));
|
|
24
|
+
assert.equal(out.displayName, "n2");
|
|
25
|
+
});
|
|
26
|
+
test("replace drops non-allowlisted keys (proto-pollution guard)", () => {
|
|
27
|
+
const out = applyPatchOps(group(), patch({ op: "replace", value: { __proto__: { polluted: true }, constructor: 1, displayName: "ok" } }));
|
|
28
|
+
assert.equal(out.displayName, "ok");
|
|
29
|
+
assert.equal({}.polluted, undefined);
|
|
30
|
+
assert.equal(Object.prototype.hasOwnProperty.call(out, "__proto__"), false);
|
|
31
|
+
assert.equal(Object.prototype.hasOwnProperty.call(out, "constructor"), false);
|
|
32
|
+
});
|
|
33
|
+
// --- add members -----------------------------------------------------
|
|
34
|
+
test("add appends a member to an empty group", () => {
|
|
35
|
+
const out = applyPatchOps(group(), patch({ op: "add", path: "members", value: [{ value: "u1", display: "Alice" }] }));
|
|
36
|
+
assert.deepEqual(out.members, [{ value: "u1", display: "Alice" }]);
|
|
37
|
+
});
|
|
38
|
+
test("add appends to existing members and dedups by value", () => {
|
|
39
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: [{ value: "u1" }, { value: "u2" }] }));
|
|
40
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
|
|
41
|
+
});
|
|
42
|
+
test("add accepts a single (non-array) value", () => {
|
|
43
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: { value: "u2" } }));
|
|
44
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
|
|
45
|
+
});
|
|
46
|
+
test("pathless add appends array attrs + sets scalars", () => {
|
|
47
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", value: { members: [{ value: "u2" }], displayName: "renamed" } }));
|
|
48
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
|
|
49
|
+
assert.equal(out.displayName, "renamed");
|
|
50
|
+
});
|
|
51
|
+
// --- remove members --------------------------------------------------
|
|
52
|
+
test("remove with a filter drops the matching member", () => {
|
|
53
|
+
const out = applyPatchOps(group([{ value: "u1" }, { value: "u2" }, { value: "u3" }]), patch({ op: "remove", path: 'members[value eq "u2"]' }));
|
|
54
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u3"]);
|
|
55
|
+
});
|
|
56
|
+
test("remove with a non-matching filter is a no-op on contents", () => {
|
|
57
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "remove", path: 'members[value eq "ghost"]' }));
|
|
58
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1"]);
|
|
59
|
+
});
|
|
60
|
+
test("remove of the whole members attr clears it", () => {
|
|
61
|
+
const out = applyPatchOps(group([{ value: "u1" }, { value: "u2" }]), patch({ op: "remove", path: "members" }));
|
|
62
|
+
assert.deepEqual(out.members, []);
|
|
63
|
+
});
|
|
64
|
+
// --- chained ops in one request (Entra-style) ------------------------
|
|
65
|
+
test("add then remove in one request compose against the working value", () => {
|
|
66
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: [{ value: "u2" }, { value: "u3" }] }, { op: "remove", path: 'members[value eq "u1"]' }));
|
|
67
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u2", "u3"]);
|
|
68
|
+
});
|
|
69
|
+
// --- security: filtered paths can't pollute --------------------------
|
|
70
|
+
test("crafted __proto__ filter path is rejected (fail-closed no-op), no pollution", () => {
|
|
71
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "remove", path: 'members[__proto__ eq "x"]' }));
|
|
72
|
+
// The sub-attribute regex requires a leading letter, so this path
|
|
73
|
+
// doesn't parse as a filter and isn't a bare allow-listed attr —
|
|
74
|
+
// the op is skipped entirely. No members key is emitted (no change)
|
|
75
|
+
// and nothing is polluted.
|
|
76
|
+
assert.equal(Object.prototype.hasOwnProperty.call(out, "members"), false);
|
|
77
|
+
assert.equal({}.x, undefined);
|
|
78
|
+
});
|
|
79
|
+
test("add to a non-allowlisted path is ignored", () => {
|
|
80
|
+
const out = applyPatchOps(group(), patch({ op: "add", path: "__proto__", value: { polluted: true } }));
|
|
81
|
+
assert.equal(Object.keys(out).length, 0);
|
|
82
|
+
assert.equal({}.polluted, undefined);
|
|
83
|
+
});
|
|
84
|
+
// --- emails array (same machinery) -----------------------------------
|
|
85
|
+
test("add + remove works on emails too", () => {
|
|
86
|
+
const user = {
|
|
87
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
88
|
+
id: "u1",
|
|
89
|
+
userName: "a@x",
|
|
90
|
+
emails: [{ value: "a@x", primary: true }],
|
|
91
|
+
meta: { resourceType: "User", created: "2026-06-06T00:00:00Z", lastModified: "2026-06-06T00:00:00Z" },
|
|
92
|
+
};
|
|
93
|
+
const added = applyPatchOps(user, patch({ op: "add", path: "emails", value: [{ value: "a2@x" }] }));
|
|
94
|
+
assert.deepEqual(added.emails.map((e) => e.value), ["a@x", "a2@x"]);
|
|
95
|
+
const removed = applyPatchOps(user, patch({ op: "remove", path: 'emails[value eq "a@x"]' }));
|
|
96
|
+
assert.deepEqual(removed.emails.map((e) => e.value), []);
|
|
97
|
+
});
|
|
98
|
+
test("missing target resource throws", () => {
|
|
99
|
+
assert.throws(() => applyPatchOps(undefined, patch({ op: "add", path: "members", value: [] })), /not found/);
|
|
100
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ScimGroup, type ScimUser } from "./types.js";
|
|
2
|
+
import { type IScimStore } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Redis surface we depend on. Matches both `ioredis` and the
|
|
5
|
+
* built-in promisified node-redis client — easier to swap clients
|
|
6
|
+
* and trivial to fake in unit tests.
|
|
7
|
+
*/
|
|
8
|
+
export interface RedisLike {
|
|
9
|
+
get(key: string): Promise<string | null>;
|
|
10
|
+
set(key: string, value: string): Promise<unknown>;
|
|
11
|
+
}
|
|
12
|
+
export declare class RedisScimStore implements IScimStore {
|
|
13
|
+
private readonly redis;
|
|
14
|
+
private readonly key;
|
|
15
|
+
private snapshot;
|
|
16
|
+
private bootstrapped;
|
|
17
|
+
private writeQueue;
|
|
18
|
+
constructor(redis: RedisLike, opts?: {
|
|
19
|
+
key?: string;
|
|
20
|
+
});
|
|
21
|
+
load(): Promise<void>;
|
|
22
|
+
listUsers(): ScimUser[];
|
|
23
|
+
getUser(id: string): ScimUser | undefined;
|
|
24
|
+
getUserByUserName(userName: string): ScimUser | undefined;
|
|
25
|
+
createUser(input: Partial<ScimUser>): Promise<ScimUser>;
|
|
26
|
+
updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
|
|
27
|
+
deleteUser(id: string): Promise<boolean>;
|
|
28
|
+
listGroups(): ScimGroup[];
|
|
29
|
+
getGroup(id: string): ScimGroup | undefined;
|
|
30
|
+
createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
31
|
+
updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
32
|
+
deleteGroup(id: string): Promise<boolean>;
|
|
33
|
+
groupsContaining(userId: string): Array<{
|
|
34
|
+
value: string;
|
|
35
|
+
display?: string;
|
|
36
|
+
}>;
|
|
37
|
+
private persist;
|
|
38
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Redis-backed SCIM store — same surface as the file-backed ScimStore,
|
|
2
|
+
// persists the snapshot in a single Redis key.
|
|
3
|
+
//
|
|
4
|
+
// Multi-replica deployments need a shared store so that a Microsoft
|
|
5
|
+
// Entra / Okta SCIM-push delivered to replica A is visible to replica
|
|
6
|
+
// B's reads. The file store can't do that without a shared filesystem;
|
|
7
|
+
// this one targets the same Redis the F8 SessionStore + the F8b
|
|
8
|
+
// transport-map ride on (Q11 promotes the transport-map onto the same
|
|
9
|
+
// interface).
|
|
10
|
+
//
|
|
11
|
+
// Concurrency note. SCIM clients (Entra, Okta, JumpCloud, generic
|
|
12
|
+
// SCIM) deliver provisioning requests SERIALLY per resource — the
|
|
13
|
+
// upstream IDP holds the connection open until the gateway responds.
|
|
14
|
+
// A single load-balanced gateway in front of N replicas observes one
|
|
15
|
+
// in-flight request per resource at a time, so last-writer-wins on
|
|
16
|
+
// the single-key snapshot matches the source-of-truth semantics of
|
|
17
|
+
// SCIM provisioning. We still serialise persists within a replica
|
|
18
|
+
// via a small mutex so concurrent route handlers don't lose writes
|
|
19
|
+
// to each other in the read-modify-write window.
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { nowIso, SCIM_SCHEMA_GROUP, SCIM_SCHEMA_USER, } from "./types.js";
|
|
22
|
+
import { ScimNotFoundError, ScimValidationError } from "./store.js";
|
|
23
|
+
const DEFAULT_KEY = "omcp:scim:snapshot";
|
|
24
|
+
export class RedisScimStore {
|
|
25
|
+
redis;
|
|
26
|
+
key;
|
|
27
|
+
snapshot = { users: [], groups: [] };
|
|
28
|
+
bootstrapped = null;
|
|
29
|
+
writeQueue = Promise.resolve();
|
|
30
|
+
constructor(redis, opts = {}) {
|
|
31
|
+
this.redis = redis;
|
|
32
|
+
this.key = opts.key || DEFAULT_KEY;
|
|
33
|
+
}
|
|
34
|
+
async load() {
|
|
35
|
+
if (this.bootstrapped)
|
|
36
|
+
return this.bootstrapped;
|
|
37
|
+
this.bootstrapped = (async () => {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await this.redis.get(this.key);
|
|
40
|
+
if (!raw) {
|
|
41
|
+
this.snapshot = { users: [], groups: [] };
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
this.snapshot = {
|
|
46
|
+
users: Array.isArray(parsed.users) ? parsed.users : [],
|
|
47
|
+
groups: Array.isArray(parsed.groups) ? parsed.groups : [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.warn(`[scim] failed to load redis snapshot ${this.key}: ${err.message} — starting empty`);
|
|
52
|
+
this.snapshot = { users: [], groups: [] };
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return this.bootstrapped;
|
|
56
|
+
}
|
|
57
|
+
listUsers() {
|
|
58
|
+
return this.snapshot.users.slice();
|
|
59
|
+
}
|
|
60
|
+
getUser(id) {
|
|
61
|
+
return this.snapshot.users.find((u) => u.id === id);
|
|
62
|
+
}
|
|
63
|
+
getUserByUserName(userName) {
|
|
64
|
+
return this.snapshot.users.find((u) => u.userName === userName);
|
|
65
|
+
}
|
|
66
|
+
async createUser(input) {
|
|
67
|
+
if (!input.userName)
|
|
68
|
+
throw new ScimValidationError("userName is required");
|
|
69
|
+
if (this.getUserByUserName(input.userName)) {
|
|
70
|
+
throw new ScimValidationError(`User with userName '${input.userName}' already exists`, "uniqueness");
|
|
71
|
+
}
|
|
72
|
+
const ts = nowIso();
|
|
73
|
+
const user = {
|
|
74
|
+
schemas: [SCIM_SCHEMA_USER],
|
|
75
|
+
id: randomUUID(),
|
|
76
|
+
userName: input.userName,
|
|
77
|
+
active: input.active ?? true,
|
|
78
|
+
displayName: input.displayName,
|
|
79
|
+
name: input.name,
|
|
80
|
+
emails: input.emails,
|
|
81
|
+
externalId: input.externalId,
|
|
82
|
+
meta: { resourceType: "User", created: ts, lastModified: ts },
|
|
83
|
+
};
|
|
84
|
+
this.snapshot.users.push(user);
|
|
85
|
+
await this.persist();
|
|
86
|
+
return user;
|
|
87
|
+
}
|
|
88
|
+
async updateUser(id, patch) {
|
|
89
|
+
const i = this.snapshot.users.findIndex((u) => u.id === id);
|
|
90
|
+
if (i < 0)
|
|
91
|
+
throw new ScimNotFoundError(`User ${id} not found`);
|
|
92
|
+
const next = {
|
|
93
|
+
...this.snapshot.users[i],
|
|
94
|
+
...patch,
|
|
95
|
+
schemas: [SCIM_SCHEMA_USER],
|
|
96
|
+
id,
|
|
97
|
+
meta: {
|
|
98
|
+
...this.snapshot.users[i].meta,
|
|
99
|
+
lastModified: nowIso(),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
this.snapshot.users[i] = next;
|
|
103
|
+
await this.persist();
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
async deleteUser(id) {
|
|
107
|
+
const before = this.snapshot.users.length;
|
|
108
|
+
this.snapshot.users = this.snapshot.users.filter((u) => u.id !== id);
|
|
109
|
+
if (this.snapshot.users.length === before)
|
|
110
|
+
return false;
|
|
111
|
+
for (const g of this.snapshot.groups) {
|
|
112
|
+
g.members = (g.members ?? []).filter((m) => m.value !== id);
|
|
113
|
+
}
|
|
114
|
+
await this.persist();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
listGroups() {
|
|
118
|
+
return this.snapshot.groups.slice();
|
|
119
|
+
}
|
|
120
|
+
getGroup(id) {
|
|
121
|
+
return this.snapshot.groups.find((g) => g.id === id);
|
|
122
|
+
}
|
|
123
|
+
async createGroup(input) {
|
|
124
|
+
if (!input.displayName)
|
|
125
|
+
throw new ScimValidationError("displayName is required");
|
|
126
|
+
const ts = nowIso();
|
|
127
|
+
const group = {
|
|
128
|
+
schemas: [SCIM_SCHEMA_GROUP],
|
|
129
|
+
id: randomUUID(),
|
|
130
|
+
displayName: input.displayName,
|
|
131
|
+
members: input.members ?? [],
|
|
132
|
+
externalId: input.externalId,
|
|
133
|
+
meta: { resourceType: "Group", created: ts, lastModified: ts },
|
|
134
|
+
};
|
|
135
|
+
this.snapshot.groups.push(group);
|
|
136
|
+
await this.persist();
|
|
137
|
+
return group;
|
|
138
|
+
}
|
|
139
|
+
async updateGroup(id, patch) {
|
|
140
|
+
const i = this.snapshot.groups.findIndex((g) => g.id === id);
|
|
141
|
+
if (i < 0)
|
|
142
|
+
throw new ScimNotFoundError(`Group ${id} not found`);
|
|
143
|
+
const next = {
|
|
144
|
+
...this.snapshot.groups[i],
|
|
145
|
+
...patch,
|
|
146
|
+
schemas: [SCIM_SCHEMA_GROUP],
|
|
147
|
+
id,
|
|
148
|
+
meta: {
|
|
149
|
+
...this.snapshot.groups[i].meta,
|
|
150
|
+
lastModified: nowIso(),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
this.snapshot.groups[i] = next;
|
|
154
|
+
await this.persist();
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
async deleteGroup(id) {
|
|
158
|
+
const before = this.snapshot.groups.length;
|
|
159
|
+
this.snapshot.groups = this.snapshot.groups.filter((g) => g.id !== id);
|
|
160
|
+
if (this.snapshot.groups.length === before)
|
|
161
|
+
return false;
|
|
162
|
+
await this.persist();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
groupsContaining(userId) {
|
|
166
|
+
return this.snapshot.groups
|
|
167
|
+
.filter((g) => (g.members ?? []).some((m) => m.value === userId))
|
|
168
|
+
.map((g) => ({ value: g.id, display: g.displayName }));
|
|
169
|
+
}
|
|
170
|
+
persist() {
|
|
171
|
+
// Serialise persists so two concurrent updateUser calls don't
|
|
172
|
+
// race each other to the SET — the snapshot in memory is the
|
|
173
|
+
// canonical state, Redis just mirrors it.
|
|
174
|
+
const snap = { users: this.snapshot.users.slice(), groups: this.snapshot.groups.slice() };
|
|
175
|
+
this.writeQueue = this.writeQueue.then(() => this.redis.set(this.key, JSON.stringify(snap)).then(() => undefined));
|
|
176
|
+
return this.writeQueue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { RedisScimStore } from "./redis-store.js";
|
|
4
|
+
import { ScimNotFoundError, ScimValidationError } from "./store.js";
|
|
5
|
+
/** In-memory fake of the RedisLike surface — single-key GET/SET. */
|
|
6
|
+
function fakeRedis(initial) {
|
|
7
|
+
const store = new Map(Object.entries(initial || {}));
|
|
8
|
+
return {
|
|
9
|
+
_store: store,
|
|
10
|
+
_writeCount: 0,
|
|
11
|
+
async get(key) { return store.has(key) ? store.get(key) : null; },
|
|
12
|
+
async set(key, value) {
|
|
13
|
+
store.set(key, value);
|
|
14
|
+
this._writeCount += 1;
|
|
15
|
+
return "OK";
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
test("load() initialises empty when redis key is missing", async () => {
|
|
20
|
+
const r = fakeRedis();
|
|
21
|
+
const s = new RedisScimStore(r);
|
|
22
|
+
await s.load();
|
|
23
|
+
assert.deepEqual(s.listUsers(), []);
|
|
24
|
+
assert.deepEqual(s.listGroups(), []);
|
|
25
|
+
});
|
|
26
|
+
test("load() hydrates from a serialised snapshot in redis", async () => {
|
|
27
|
+
const r = fakeRedis({
|
|
28
|
+
"omcp:scim:snapshot": JSON.stringify({
|
|
29
|
+
users: [{ id: "u1", userName: "alice@example.com", schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], meta: { resourceType: "User" } }],
|
|
30
|
+
groups: [],
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const s = new RedisScimStore(r);
|
|
34
|
+
await s.load();
|
|
35
|
+
assert.equal(s.listUsers().length, 1);
|
|
36
|
+
assert.equal(s.listUsers()[0].userName, "alice@example.com");
|
|
37
|
+
});
|
|
38
|
+
test("createUser persists to redis", async () => {
|
|
39
|
+
const r = fakeRedis();
|
|
40
|
+
const s = new RedisScimStore(r);
|
|
41
|
+
await s.load();
|
|
42
|
+
const u = await s.createUser({ userName: "bob@example.com" });
|
|
43
|
+
assert.ok(u.id);
|
|
44
|
+
assert.equal(u.userName, "bob@example.com");
|
|
45
|
+
// Persisted snapshot round-trips through redis
|
|
46
|
+
const raw = JSON.parse(r._store.get("omcp:scim:snapshot"));
|
|
47
|
+
assert.equal(raw.users.length, 1);
|
|
48
|
+
assert.equal(raw.users[0].userName, "bob@example.com");
|
|
49
|
+
});
|
|
50
|
+
test("createUser enforces unique userName", async () => {
|
|
51
|
+
const r = fakeRedis();
|
|
52
|
+
const s = new RedisScimStore(r);
|
|
53
|
+
await s.load();
|
|
54
|
+
await s.createUser({ userName: "alice@example.com" });
|
|
55
|
+
await assert.rejects(s.createUser({ userName: "alice@example.com" }), ScimValidationError);
|
|
56
|
+
});
|
|
57
|
+
test("createUser without userName throws", async () => {
|
|
58
|
+
const r = fakeRedis();
|
|
59
|
+
const s = new RedisScimStore(r);
|
|
60
|
+
await s.load();
|
|
61
|
+
await assert.rejects(s.createUser({}), ScimValidationError);
|
|
62
|
+
});
|
|
63
|
+
test("updateUser preserves id + sets lastModified to a not-earlier ts", async () => {
|
|
64
|
+
const r = fakeRedis();
|
|
65
|
+
const s = new RedisScimStore(r);
|
|
66
|
+
await s.load();
|
|
67
|
+
const u = await s.createUser({ userName: "u@x" });
|
|
68
|
+
// 2ms gap so the ISO timestamp differs even on slow CI clocks
|
|
69
|
+
await new Promise((res) => setTimeout(res, 2));
|
|
70
|
+
const u2 = await s.updateUser(u.id, { displayName: "U" });
|
|
71
|
+
assert.equal(u2.id, u.id);
|
|
72
|
+
assert.equal(u2.displayName, "U");
|
|
73
|
+
assert.ok(u2.meta.lastModified >= u.meta.lastModified);
|
|
74
|
+
});
|
|
75
|
+
test("updateUser missing throws ScimNotFoundError", async () => {
|
|
76
|
+
const r = fakeRedis();
|
|
77
|
+
const s = new RedisScimStore(r);
|
|
78
|
+
await s.load();
|
|
79
|
+
await assert.rejects(s.updateUser("nope", { displayName: "x" }), ScimNotFoundError);
|
|
80
|
+
});
|
|
81
|
+
test("deleteUser purges the user from all group member lists", async () => {
|
|
82
|
+
const r = fakeRedis();
|
|
83
|
+
const s = new RedisScimStore(r);
|
|
84
|
+
await s.load();
|
|
85
|
+
const u = await s.createUser({ userName: "u@x" });
|
|
86
|
+
const g = await s.createGroup({ displayName: "admins", members: [{ value: u.id, display: "u@x" }] });
|
|
87
|
+
await s.deleteUser(u.id);
|
|
88
|
+
const updated = s.getGroup(g.id);
|
|
89
|
+
assert.equal(updated?.members?.length, 0);
|
|
90
|
+
});
|
|
91
|
+
test("group CRUD round-trip persists", async () => {
|
|
92
|
+
const r = fakeRedis();
|
|
93
|
+
const s = new RedisScimStore(r);
|
|
94
|
+
await s.load();
|
|
95
|
+
const g = await s.createGroup({ displayName: "team-alpha" });
|
|
96
|
+
assert.equal(g.displayName, "team-alpha");
|
|
97
|
+
const g2 = await s.updateGroup(g.id, { displayName: "team-beta" });
|
|
98
|
+
assert.equal(g2.displayName, "team-beta");
|
|
99
|
+
assert.equal((await s.deleteGroup(g.id)), true);
|
|
100
|
+
assert.equal((await s.deleteGroup(g.id)), false);
|
|
101
|
+
});
|
|
102
|
+
test("groupsContaining returns membership", async () => {
|
|
103
|
+
const r = fakeRedis();
|
|
104
|
+
const s = new RedisScimStore(r);
|
|
105
|
+
await s.load();
|
|
106
|
+
const u = await s.createUser({ userName: "u@x" });
|
|
107
|
+
const g = await s.createGroup({ displayName: "admins", members: [{ value: u.id, display: "u@x" }] });
|
|
108
|
+
const groups = s.groupsContaining(u.id);
|
|
109
|
+
assert.equal(groups.length, 1);
|
|
110
|
+
assert.equal(groups[0].value, g.id);
|
|
111
|
+
assert.equal(groups[0].display, "admins");
|
|
112
|
+
});
|
|
113
|
+
test("custom key is honoured", async () => {
|
|
114
|
+
const r = fakeRedis();
|
|
115
|
+
const s = new RedisScimStore(r, { key: "custom:key" });
|
|
116
|
+
await s.load();
|
|
117
|
+
await s.createUser({ userName: "u@x" });
|
|
118
|
+
assert.ok(r._store.has("custom:key"));
|
|
119
|
+
assert.equal(r._store.has("omcp:scim:snapshot"), false);
|
|
120
|
+
});
|
|
121
|
+
test("concurrent writes serialise — no lost-write race", async () => {
|
|
122
|
+
const r = fakeRedis();
|
|
123
|
+
const s = new RedisScimStore(r);
|
|
124
|
+
await s.load();
|
|
125
|
+
const u1 = s.createUser({ userName: "a@x" });
|
|
126
|
+
const u2 = s.createUser({ userName: "b@x" });
|
|
127
|
+
const u3 = s.createUser({ userName: "c@x" });
|
|
128
|
+
await Promise.all([u1, u2, u3]);
|
|
129
|
+
const final = JSON.parse(r._store.get("omcp:scim:snapshot"));
|
|
130
|
+
assert.equal(final.users.length, 3);
|
|
131
|
+
});
|
|
132
|
+
test("malformed snapshot in redis → starts empty (warning logged)", async () => {
|
|
133
|
+
const r = fakeRedis({ "omcp:scim:snapshot": "this is not json" });
|
|
134
|
+
const s = new RedisScimStore(r);
|
|
135
|
+
await s.load();
|
|
136
|
+
assert.deepEqual(s.listUsers(), []);
|
|
137
|
+
assert.deepEqual(s.listGroups(), []);
|
|
138
|
+
});
|
package/dist/scim/routes.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Application } from "express";
|
|
2
|
-
import {
|
|
2
|
+
import { type ScimPatchRequest } from "./types.js";
|
|
3
|
+
import { type IScimStore } from "./store.js";
|
|
3
4
|
export interface ScimRoutesDeps {
|
|
4
|
-
store:
|
|
5
|
+
store: IScimStore;
|
|
5
6
|
bearerToken: string;
|
|
6
7
|
/** Audit hook called after every successful mutation. Best-effort. */
|
|
7
8
|
audit?: (event: {
|
|
@@ -13,3 +14,27 @@ export interface ScimRoutesDeps {
|
|
|
13
14
|
}) => void;
|
|
14
15
|
}
|
|
15
16
|
export declare function registerScimRoutes(app: Application, deps: ScimRoutesDeps): void;
|
|
17
|
+
/** Translate a SCIM PatchOp request into a partial resource patch.
|
|
18
|
+
*
|
|
19
|
+
* Supported (RFC 7644 §3.5.2):
|
|
20
|
+
* - replace, no path → whole-resource merge of allow-listed keys
|
|
21
|
+
* - replace, path=attr → set that attribute
|
|
22
|
+
* - add, no path → merge; array attrs append (deduped), scalars set
|
|
23
|
+
* - add, path=arrayAttr → append value(s) to the array (deduped)
|
|
24
|
+
* - add, path=scalarAttr → set
|
|
25
|
+
* - remove, path=arrayAttr → clear the array
|
|
26
|
+
* - remove, path=arrayAttr[sub eq "x"] → drop matching elements
|
|
27
|
+
* - remove, path=scalarAttr → clear the attribute (set undefined)
|
|
28
|
+
*
|
|
29
|
+
* Array ops are computed against the CURRENT resource value and the
|
|
30
|
+
* full resulting array is returned in `out` (the store merges
|
|
31
|
+
* shallowly, so out[attr] replaces current[attr] wholesale).
|
|
32
|
+
*
|
|
33
|
+
* Every property name written into `out` is gated through
|
|
34
|
+
* `PATCH_ALLOWED_ATTRS` so a SCIM client can't inject __proto__ /
|
|
35
|
+
* constructor / arbitrary fields (CodeQL js/remote-property-injection +
|
|
36
|
+
* js/prototype-polluting-assignment). Filter sub-attributes are
|
|
37
|
+
* read-only. */
|
|
38
|
+
export declare function applyPatchOps<T extends {
|
|
39
|
+
id: string;
|
|
40
|
+
}>(current: T | undefined, patch: ScimPatchRequest): Partial<T>;
|