@thotischner/observability-mcp 3.0.0 → 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/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/policy/batch-dry-run.js +15 -0
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- 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 +306 -65
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -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/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -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/ui/index.html +856 -101
- package/package.json +1 -1
package/dist/policy/redact.js
CHANGED
|
@@ -31,7 +31,7 @@ const PATTERNS = [
|
|
|
31
31
|
// - PEM private-key blocks: greedy match across newlines.
|
|
32
32
|
{ category: "aws-key", re: /\b(?:AKIA|ASIA|AROA)[0-9A-Z]{16,20}\b/g },
|
|
33
33
|
{ category: "slack-token", re: /\bxox[abprsu]-[A-Za-z0-9-]{10,}\b/g },
|
|
34
|
-
{ category: "gh-pat", re: /\b(?:github_pat_[A-Za-z0-9_]{40,}|gh[
|
|
34
|
+
{ category: "gh-pat", re: /\b(?:github_pat_[A-Za-z0-9_]{40,}|gh[oprsu]_[A-Za-z0-9]{36})\b/g },
|
|
35
35
|
{ category: "private-key", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
36
36
|
// emails before other patterns so they don't get eaten partially
|
|
37
37
|
{ category: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PostmortemReport } from "./synthesizer.js";
|
|
2
|
+
export interface StoredPostmortem {
|
|
3
|
+
id: string;
|
|
4
|
+
/** RFC-3339 timestamp of when the report was generated. */
|
|
5
|
+
ts: string;
|
|
6
|
+
/** Subject identity that called generate_postmortem. */
|
|
7
|
+
createdBy: string;
|
|
8
|
+
/** Tenant the report belongs to. */
|
|
9
|
+
tenant: string;
|
|
10
|
+
/** The shipped report shape — service + window + synopsis +
|
|
11
|
+
* markdown + sections. */
|
|
12
|
+
report: PostmortemReport;
|
|
13
|
+
}
|
|
14
|
+
export declare class PostmortemStore {
|
|
15
|
+
private readonly path;
|
|
16
|
+
private entries;
|
|
17
|
+
private bootstrapped;
|
|
18
|
+
constructor(path: string);
|
|
19
|
+
load(): Promise<void>;
|
|
20
|
+
/** List entries, newest-first. Optionally scoped to a tenant. */
|
|
21
|
+
list(tenant?: string): StoredPostmortem[];
|
|
22
|
+
get(id: string, tenant?: string): StoredPostmortem | undefined;
|
|
23
|
+
/** Append a freshly-generated report. Returns the stored entry
|
|
24
|
+
* with its assigned id + ts. */
|
|
25
|
+
append(input: {
|
|
26
|
+
report: PostmortemReport;
|
|
27
|
+
createdBy: string;
|
|
28
|
+
tenant: string;
|
|
29
|
+
}): Promise<StoredPostmortem>;
|
|
30
|
+
/** Delete one entry by id. Atomic rewrite. Returns whether
|
|
31
|
+
* anything was removed. */
|
|
32
|
+
delete(id: string, tenant?: string): Promise<boolean>;
|
|
33
|
+
private rewrite;
|
|
34
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// PostmortemStore — file-backed JSONL persistence for
|
|
2
|
+
// generate_postmortem output.
|
|
3
|
+
//
|
|
4
|
+
// Design notes:
|
|
5
|
+
// - One JSON object per line (append-only). Cheap to tail, cheap
|
|
6
|
+
// to scan, surives crashes mid-write (the partial line is just
|
|
7
|
+
// ignored on load).
|
|
8
|
+
// - load() reads the whole file into an in-memory array (in
|
|
9
|
+
// practice operators don't accumulate thousands; the tool
|
|
10
|
+
// produces one report per incident).
|
|
11
|
+
// - delete() rewrites the file atomically (tmp + rename). Same
|
|
12
|
+
// pattern as the SCIM store from F21.
|
|
13
|
+
//
|
|
14
|
+
// The schema is intentionally narrow — we store what the tool's
|
|
15
|
+
// PostmortemReport already returns plus an id, ts, and createdBy.
|
|
16
|
+
import { readFile, writeFile, mkdir, rename, appendFile } from "node:fs/promises";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
export class PostmortemStore {
|
|
20
|
+
path;
|
|
21
|
+
entries = [];
|
|
22
|
+
bootstrapped = null;
|
|
23
|
+
constructor(path) {
|
|
24
|
+
this.path = path;
|
|
25
|
+
}
|
|
26
|
+
async load() {
|
|
27
|
+
if (this.bootstrapped)
|
|
28
|
+
return this.bootstrapped;
|
|
29
|
+
this.bootstrapped = (async () => {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(this.path, "utf8");
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
const t = line.trim();
|
|
35
|
+
if (!t)
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const obj = JSON.parse(t);
|
|
39
|
+
if (obj && typeof obj.id === "string")
|
|
40
|
+
out.push(obj);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Partial / corrupt line — skip, don't fail load. The
|
|
44
|
+
// operator can purge by re-saving with delete().
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.entries = out;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT") {
|
|
51
|
+
this.entries = [];
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.warn(`[postmortem-store] failed to load ${this.path}: ${err.message} — starting empty`);
|
|
55
|
+
this.entries = [];
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
return this.bootstrapped;
|
|
59
|
+
}
|
|
60
|
+
/** List entries, newest-first. Optionally scoped to a tenant. */
|
|
61
|
+
list(tenant) {
|
|
62
|
+
const src = tenant ? this.entries.filter((e) => e.tenant === tenant) : this.entries;
|
|
63
|
+
return src.slice().sort((a, b) => b.ts.localeCompare(a.ts));
|
|
64
|
+
}
|
|
65
|
+
get(id, tenant) {
|
|
66
|
+
const e = this.entries.find((x) => x.id === id);
|
|
67
|
+
if (!e)
|
|
68
|
+
return undefined;
|
|
69
|
+
if (tenant && e.tenant !== tenant)
|
|
70
|
+
return undefined;
|
|
71
|
+
return e;
|
|
72
|
+
}
|
|
73
|
+
/** Append a freshly-generated report. Returns the stored entry
|
|
74
|
+
* with its assigned id + ts. */
|
|
75
|
+
async append(input) {
|
|
76
|
+
const entry = {
|
|
77
|
+
id: randomUUID(),
|
|
78
|
+
ts: new Date().toISOString(),
|
|
79
|
+
createdBy: input.createdBy,
|
|
80
|
+
tenant: input.tenant,
|
|
81
|
+
report: input.report,
|
|
82
|
+
};
|
|
83
|
+
this.entries.push(entry);
|
|
84
|
+
await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
|
|
85
|
+
// Append-only — atomic enough for a JSONL (one write = one
|
|
86
|
+
// syscall; partial writes are skipped on load).
|
|
87
|
+
await appendFile(this.path, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
88
|
+
return entry;
|
|
89
|
+
}
|
|
90
|
+
/** Delete one entry by id. Atomic rewrite. Returns whether
|
|
91
|
+
* anything was removed. */
|
|
92
|
+
async delete(id, tenant) {
|
|
93
|
+
const before = this.entries.length;
|
|
94
|
+
this.entries = this.entries.filter((e) => {
|
|
95
|
+
if (e.id !== id)
|
|
96
|
+
return true;
|
|
97
|
+
if (tenant && e.tenant !== tenant)
|
|
98
|
+
return true;
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
if (this.entries.length === before)
|
|
102
|
+
return false;
|
|
103
|
+
await this.rewrite();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
async rewrite() {
|
|
107
|
+
await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
|
|
108
|
+
const body = this.entries.map((e) => JSON.stringify(e)).join("\n") + (this.entries.length ? "\n" : "");
|
|
109
|
+
const tmp = `${this.path}.tmp`;
|
|
110
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
111
|
+
await rename(tmp, this.path);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, statSync, existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { PostmortemStore } from "./store.js";
|
|
7
|
+
function tmpStore() {
|
|
8
|
+
return join(mkdtempSync(join(tmpdir(), "pmstore-")), "postmortems.jsonl");
|
|
9
|
+
}
|
|
10
|
+
function fakeReport(service = "payment") {
|
|
11
|
+
return {
|
|
12
|
+
service,
|
|
13
|
+
window: "1h",
|
|
14
|
+
fromIso: "2026-06-06T00:00:00.000Z",
|
|
15
|
+
toIso: "2026-06-06T01:00:00.000Z",
|
|
16
|
+
synopsis: "test",
|
|
17
|
+
markdown: "# Test",
|
|
18
|
+
sections: {
|
|
19
|
+
timeline: [],
|
|
20
|
+
blastRadius: { nodes: [], edgeCount: 0 },
|
|
21
|
+
topTraces: [],
|
|
22
|
+
contributingSignals: [],
|
|
23
|
+
followUps: [],
|
|
24
|
+
logHighlights: [],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
test("PostmortemStore: load() on missing file → empty list", async () => {
|
|
29
|
+
const s = new PostmortemStore(tmpStore());
|
|
30
|
+
await s.load();
|
|
31
|
+
assert.deepEqual(s.list(), []);
|
|
32
|
+
});
|
|
33
|
+
test("PostmortemStore: append issues UUID + ISO ts, persists JSONL", async () => {
|
|
34
|
+
const path = tmpStore();
|
|
35
|
+
const s = new PostmortemStore(path);
|
|
36
|
+
await s.load();
|
|
37
|
+
const stored = await s.append({ report: fakeReport(), createdBy: "alice", tenant: "default" });
|
|
38
|
+
assert.match(stored.id, /^[0-9a-f-]{36}$/);
|
|
39
|
+
assert.match(stored.ts, /^\d{4}-\d{2}-\d{2}T/);
|
|
40
|
+
assert.equal(stored.report.service, "payment");
|
|
41
|
+
// disk format is one JSON per line
|
|
42
|
+
const raw = readFileSync(path, "utf8");
|
|
43
|
+
assert.equal(raw.split("\n").filter((l) => l).length, 1);
|
|
44
|
+
assert.equal(statSync(path).mode & 0o777, 0o600);
|
|
45
|
+
});
|
|
46
|
+
test("PostmortemStore: list() returns newest-first", async () => {
|
|
47
|
+
const s = new PostmortemStore(tmpStore());
|
|
48
|
+
await s.load();
|
|
49
|
+
await s.append({ report: fakeReport("a"), createdBy: "u", tenant: "t" });
|
|
50
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
51
|
+
await s.append({ report: fakeReport("b"), createdBy: "u", tenant: "t" });
|
|
52
|
+
const out = s.list();
|
|
53
|
+
assert.equal(out[0].report.service, "b");
|
|
54
|
+
assert.equal(out[1].report.service, "a");
|
|
55
|
+
});
|
|
56
|
+
test("PostmortemStore: list(tenant) scopes correctly", async () => {
|
|
57
|
+
const s = new PostmortemStore(tmpStore());
|
|
58
|
+
await s.load();
|
|
59
|
+
await s.append({ report: fakeReport("a"), createdBy: "u", tenant: "alpha" });
|
|
60
|
+
await s.append({ report: fakeReport("b"), createdBy: "u", tenant: "beta" });
|
|
61
|
+
assert.equal(s.list("alpha").length, 1);
|
|
62
|
+
assert.equal(s.list("alpha")[0].report.service, "a");
|
|
63
|
+
});
|
|
64
|
+
test("PostmortemStore: get() by id, tenant-scoped", async () => {
|
|
65
|
+
const s = new PostmortemStore(tmpStore());
|
|
66
|
+
await s.load();
|
|
67
|
+
const e = await s.append({ report: fakeReport(), createdBy: "u", tenant: "alpha" });
|
|
68
|
+
assert.equal(s.get(e.id)?.id, e.id);
|
|
69
|
+
assert.equal(s.get(e.id, "alpha")?.id, e.id);
|
|
70
|
+
// wrong tenant → undefined
|
|
71
|
+
assert.equal(s.get(e.id, "beta"), undefined);
|
|
72
|
+
assert.equal(s.get("nope"), undefined);
|
|
73
|
+
});
|
|
74
|
+
test("PostmortemStore: delete() rewrites file + scoped by tenant", async () => {
|
|
75
|
+
const path = tmpStore();
|
|
76
|
+
const s = new PostmortemStore(path);
|
|
77
|
+
await s.load();
|
|
78
|
+
const e1 = await s.append({ report: fakeReport("a"), createdBy: "u", tenant: "alpha" });
|
|
79
|
+
await s.append({ report: fakeReport("b"), createdBy: "u", tenant: "beta" });
|
|
80
|
+
// wrong tenant → no-op
|
|
81
|
+
assert.equal(await s.delete(e1.id, "beta"), false);
|
|
82
|
+
assert.equal(s.list().length, 2);
|
|
83
|
+
// correct tenant → removed
|
|
84
|
+
assert.equal(await s.delete(e1.id, "alpha"), true);
|
|
85
|
+
assert.equal(s.list().length, 1);
|
|
86
|
+
// disk reflects the rewrite
|
|
87
|
+
const raw = readFileSync(path, "utf8");
|
|
88
|
+
assert.equal(raw.split("\n").filter((l) => l).length, 1);
|
|
89
|
+
});
|
|
90
|
+
test("PostmortemStore: delete() missing id → false", async () => {
|
|
91
|
+
const s = new PostmortemStore(tmpStore());
|
|
92
|
+
await s.load();
|
|
93
|
+
assert.equal(await s.delete("nope"), false);
|
|
94
|
+
});
|
|
95
|
+
test("PostmortemStore: round-trip through disk (load after append sees entries)", async () => {
|
|
96
|
+
const path = tmpStore();
|
|
97
|
+
const a = new PostmortemStore(path);
|
|
98
|
+
await a.load();
|
|
99
|
+
await a.append({ report: fakeReport("a"), createdBy: "u", tenant: "default" });
|
|
100
|
+
await a.append({ report: fakeReport("b"), createdBy: "u", tenant: "default" });
|
|
101
|
+
const b = new PostmortemStore(path);
|
|
102
|
+
await b.load();
|
|
103
|
+
assert.equal(b.list().length, 2);
|
|
104
|
+
});
|
|
105
|
+
test("PostmortemStore: load skips corrupt lines", async () => {
|
|
106
|
+
const path = tmpStore();
|
|
107
|
+
// Hand-write a file with one good + one garbage line
|
|
108
|
+
const a = new PostmortemStore(path);
|
|
109
|
+
await a.load();
|
|
110
|
+
await a.append({ report: fakeReport(), createdBy: "u", tenant: "default" });
|
|
111
|
+
const fs = await import("node:fs/promises");
|
|
112
|
+
await fs.appendFile(path, "{not valid json\n");
|
|
113
|
+
const b = new PostmortemStore(path);
|
|
114
|
+
await b.load();
|
|
115
|
+
// Good entry survived; corrupt line ignored.
|
|
116
|
+
assert.equal(b.list().length, 1);
|
|
117
|
+
assert.ok(existsSync(path));
|
|
118
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SCIM 2.0 (RFC 7643 / 7644) compliance harness.
|
|
2
|
+
//
|
|
3
|
+
// Run against a running gateway with SCIM enabled by setting:
|
|
4
|
+
// OMCP_SCIM_COMPLIANCE_URL — the SCIM base, e.g.
|
|
5
|
+
// http://localhost:3000/scim/v2
|
|
6
|
+
// OMCP_SCIM_COMPLIANCE_TOKEN — the bearer matching OMCP_SCIM_TOKEN
|
|
7
|
+
//
|
|
8
|
+
// When the URL is unset every test skips — so the file lives happily
|
|
9
|
+
// in a plain `find src -name "*.test.ts"` unit run without needing a
|
|
10
|
+
// server. The `make scim-compliance` target boots the demo with SCIM
|
|
11
|
+
// configured, waits for /healthz, then runs this file.
|
|
12
|
+
//
|
|
13
|
+
// OMCP_SCIM_COMPLIANCE_URL=http://localhost:3000/scim/v2 \
|
|
14
|
+
// OMCP_SCIM_COMPLIANCE_TOKEN=secret \
|
|
15
|
+
// npx tsx --test src/scim/compliance.test.ts
|
|
16
|
+
//
|
|
17
|
+
// The suite is self-contained: it creates resources, exercises them,
|
|
18
|
+
// and deletes them, leaving the store as it found it.
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
const BASE = process.env.OMCP_SCIM_COMPLIANCE_URL?.replace(/\/+$/, "");
|
|
22
|
+
const TOKEN = process.env.OMCP_SCIM_COMPLIANCE_TOKEN || "";
|
|
23
|
+
const skip = !BASE;
|
|
24
|
+
const opts = skip ? { skip: "OMCP_SCIM_COMPLIANCE_URL not set" } : {};
|
|
25
|
+
const SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
26
|
+
const SCHEMA_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
|
27
|
+
const SCHEMA_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
|
28
|
+
const SCHEMA_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
29
|
+
const SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
30
|
+
async function scim(method, path, body, withAuth = true) {
|
|
31
|
+
if (!BASE)
|
|
32
|
+
throw new Error("OMCP_SCIM_COMPLIANCE_URL not set");
|
|
33
|
+
const headers = { "content-type": "application/scim+json" };
|
|
34
|
+
if (withAuth)
|
|
35
|
+
headers["authorization"] = `Bearer ${TOKEN}`;
|
|
36
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers,
|
|
39
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
const h = {};
|
|
42
|
+
res.headers.forEach((v, k) => { h[k] = v; });
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
let json = {};
|
|
45
|
+
if (text.trim().startsWith("{"))
|
|
46
|
+
json = JSON.parse(text);
|
|
47
|
+
return { status: res.status, json, headers: h };
|
|
48
|
+
}
|
|
49
|
+
// Resources created during the run, deleted in the final test.
|
|
50
|
+
const created = { users: [], groups: [] };
|
|
51
|
+
function uniq(p) { return `${p}-${Math.floor(performance.now() * 1000)}-${created.users.length + created.groups.length}`; }
|
|
52
|
+
// --- Discovery (RFC 7643 §5) -----------------------------------------
|
|
53
|
+
test("ServiceProviderConfig advertises the spec schema + patch support", opts, async () => {
|
|
54
|
+
const r = await scim("GET", "/ServiceProviderConfig");
|
|
55
|
+
assert.equal(r.status, 200);
|
|
56
|
+
assert.ok(r.json.schemas.includes("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"));
|
|
57
|
+
assert.ok(r.json.patch, "patch capability block present");
|
|
58
|
+
});
|
|
59
|
+
test("ResourceTypes lists User + Group endpoints", opts, async () => {
|
|
60
|
+
const r = await scim("GET", "/ResourceTypes");
|
|
61
|
+
assert.equal(r.status, 200);
|
|
62
|
+
const txt = JSON.stringify(r.json);
|
|
63
|
+
assert.ok(txt.includes("/Users") && txt.includes("/Groups"));
|
|
64
|
+
});
|
|
65
|
+
test("Schemas endpoint returns the core schema definitions", opts, async () => {
|
|
66
|
+
const r = await scim("GET", "/Schemas");
|
|
67
|
+
assert.equal(r.status, 200);
|
|
68
|
+
const txt = JSON.stringify(r.json);
|
|
69
|
+
assert.ok(txt.includes(SCHEMA_USER) && txt.includes(SCHEMA_GROUP));
|
|
70
|
+
});
|
|
71
|
+
// --- Auth (RFC 7644 §2) ----------------------------------------------
|
|
72
|
+
test("requests without a bearer token are rejected 401", opts, async () => {
|
|
73
|
+
const r = await scim("GET", "/Users", undefined, false);
|
|
74
|
+
assert.equal(r.status, 401);
|
|
75
|
+
assert.ok(r.json.schemas?.includes(SCHEMA_ERROR));
|
|
76
|
+
});
|
|
77
|
+
// --- User lifecycle (RFC 7644 §3.3–3.6) ------------------------------
|
|
78
|
+
let userId = "";
|
|
79
|
+
const userName = uniq("compliance-user") + "@example.com";
|
|
80
|
+
test("POST /Users creates a user → 201 with id + meta", opts, async () => {
|
|
81
|
+
const r = await scim("POST", "/Users", {
|
|
82
|
+
schemas: [SCHEMA_USER],
|
|
83
|
+
userName,
|
|
84
|
+
name: { givenName: "Comp", familyName: "Liance" },
|
|
85
|
+
emails: [{ value: userName, primary: true }],
|
|
86
|
+
active: true,
|
|
87
|
+
});
|
|
88
|
+
assert.equal(r.status, 201);
|
|
89
|
+
assert.equal(typeof r.json.id, "string");
|
|
90
|
+
assert.equal(r.json.userName, userName);
|
|
91
|
+
const meta = r.json.meta;
|
|
92
|
+
assert.equal(meta.resourceType, "User");
|
|
93
|
+
assert.ok(meta.created && meta.lastModified);
|
|
94
|
+
userId = r.json.id;
|
|
95
|
+
created.users.push(userId);
|
|
96
|
+
});
|
|
97
|
+
test("duplicate userName is rejected 409 uniqueness", opts, async () => {
|
|
98
|
+
const r = await scim("POST", "/Users", { schemas: [SCHEMA_USER], userName });
|
|
99
|
+
assert.equal(r.status, 409);
|
|
100
|
+
assert.equal(r.json.scimType, "uniqueness");
|
|
101
|
+
});
|
|
102
|
+
test("GET /Users/:id returns the created user", opts, async () => {
|
|
103
|
+
const r = await scim("GET", `/Users/${userId}`);
|
|
104
|
+
assert.equal(r.status, 200);
|
|
105
|
+
assert.equal(r.json.id, userId);
|
|
106
|
+
assert.equal(r.json.userName, userName);
|
|
107
|
+
});
|
|
108
|
+
test("GET /Users returns a ListResponse envelope", opts, async () => {
|
|
109
|
+
const r = await scim("GET", "/Users");
|
|
110
|
+
assert.equal(r.status, 200);
|
|
111
|
+
assert.ok(r.json.schemas.includes(SCHEMA_LIST));
|
|
112
|
+
assert.equal(typeof r.json.totalResults, "number");
|
|
113
|
+
assert.ok(Array.isArray(r.json.Resources));
|
|
114
|
+
});
|
|
115
|
+
test("PATCH /Users/:id replace toggles active", opts, async () => {
|
|
116
|
+
const r = await scim("PATCH", `/Users/${userId}`, {
|
|
117
|
+
schemas: [SCHEMA_PATCH],
|
|
118
|
+
Operations: [{ op: "replace", path: "active", value: false }],
|
|
119
|
+
});
|
|
120
|
+
assert.equal(r.status, 200);
|
|
121
|
+
assert.equal(r.json.active, false);
|
|
122
|
+
});
|
|
123
|
+
test("unknown user id → 404 with SCIM error schema", opts, async () => {
|
|
124
|
+
const r = await scim("GET", "/Users/does-not-exist");
|
|
125
|
+
assert.equal(r.status, 404);
|
|
126
|
+
assert.ok(r.json.schemas.includes(SCHEMA_ERROR));
|
|
127
|
+
});
|
|
128
|
+
// --- Group lifecycle + membership PATCH (Q14) ------------------------
|
|
129
|
+
let groupId = "";
|
|
130
|
+
test("POST /Groups creates a group", opts, async () => {
|
|
131
|
+
const r = await scim("POST", "/Groups", { schemas: [SCHEMA_GROUP], displayName: uniq("compliance-grp") });
|
|
132
|
+
assert.equal(r.status, 201);
|
|
133
|
+
groupId = r.json.id;
|
|
134
|
+
created.groups.push(groupId);
|
|
135
|
+
assert.equal(r.json.meta.resourceType, "Group");
|
|
136
|
+
});
|
|
137
|
+
test("PATCH /Groups/:id add member → membership reflected", opts, async () => {
|
|
138
|
+
const r = await scim("PATCH", `/Groups/${groupId}`, {
|
|
139
|
+
schemas: [SCHEMA_PATCH],
|
|
140
|
+
Operations: [{ op: "add", path: "members", value: [{ value: userId, display: userName }] }],
|
|
141
|
+
});
|
|
142
|
+
assert.equal(r.status, 200);
|
|
143
|
+
const members = r.json.members || [];
|
|
144
|
+
assert.ok(members.some((m) => m.value === userId), "added member present");
|
|
145
|
+
});
|
|
146
|
+
test("PATCH /Groups/:id remove member by filter → membership cleared", opts, async () => {
|
|
147
|
+
const r = await scim("PATCH", `/Groups/${groupId}`, {
|
|
148
|
+
schemas: [SCHEMA_PATCH],
|
|
149
|
+
Operations: [{ op: "remove", path: `members[value eq "${userId}"]` }],
|
|
150
|
+
});
|
|
151
|
+
assert.equal(r.status, 200);
|
|
152
|
+
const members = r.json.members || [];
|
|
153
|
+
assert.ok(!members.some((m) => m.value === userId), "removed member gone");
|
|
154
|
+
});
|
|
155
|
+
// --- Cleanup (DELETE → 204, then 404) --------------------------------
|
|
156
|
+
test("DELETE created resources → 204 and subsequent GET → 404", opts, async () => {
|
|
157
|
+
for (const id of created.groups) {
|
|
158
|
+
const del = await scim("DELETE", `/Groups/${id}`);
|
|
159
|
+
assert.ok(del.status === 204 || del.status === 200, `group delete status ${del.status}`);
|
|
160
|
+
const get = await scim("GET", `/Groups/${id}`);
|
|
161
|
+
assert.equal(get.status, 404);
|
|
162
|
+
}
|
|
163
|
+
for (const id of created.users) {
|
|
164
|
+
const del = await scim("DELETE", `/Users/${id}`);
|
|
165
|
+
assert.ok(del.status === 204 || del.status === 200, `user delete status ${del.status}`);
|
|
166
|
+
const get = await scim("GET", `/Users/${id}`);
|
|
167
|
+
assert.equal(get.status, 404);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createScimStore, ScimStore } from "./store.js";
|
|
7
|
+
import { RedisScimStore } from "./redis-store.js";
|
|
8
|
+
function tmpStorePath() {
|
|
9
|
+
return join(mkdtempSync(join(tmpdir(), "omcp-scim-factory-")), "scim.json");
|
|
10
|
+
}
|
|
11
|
+
test("createScimStore default = file backend", async () => {
|
|
12
|
+
const path = tmpStorePath();
|
|
13
|
+
delete process.env.OMCP_SCIM_BACKEND;
|
|
14
|
+
const store = await createScimStore({ path });
|
|
15
|
+
assert.ok(store instanceof ScimStore);
|
|
16
|
+
// The store is usable
|
|
17
|
+
const u = await store.createUser({ userName: "a@x" });
|
|
18
|
+
assert.equal(u.userName, "a@x");
|
|
19
|
+
});
|
|
20
|
+
test("createScimStore explicit backend=file", async () => {
|
|
21
|
+
const store = await createScimStore({ backend: "file", path: tmpStorePath() });
|
|
22
|
+
assert.ok(store instanceof ScimStore);
|
|
23
|
+
});
|
|
24
|
+
test("createScimStore backend=redis requires a client", async () => {
|
|
25
|
+
await assert.rejects(createScimStore({ backend: "redis" }), /backend=redis requires a redis client/);
|
|
26
|
+
});
|
|
27
|
+
test("createScimStore backend=redis returns RedisScimStore", async () => {
|
|
28
|
+
const fake = {
|
|
29
|
+
_s: new Map(),
|
|
30
|
+
async get(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
31
|
+
async set(k, v) { this._s.set(k, v); return "OK"; },
|
|
32
|
+
};
|
|
33
|
+
const store = await createScimStore({ backend: "redis", redis: fake });
|
|
34
|
+
assert.ok(store instanceof RedisScimStore);
|
|
35
|
+
const u = await store.createUser({ userName: "u@x" });
|
|
36
|
+
assert.equal(u.userName, "u@x");
|
|
37
|
+
// Persisted to the fake redis
|
|
38
|
+
assert.ok(fake._s.has("omcp:scim:snapshot"));
|
|
39
|
+
});
|
|
40
|
+
test("createScimStore reads OMCP_SCIM_BACKEND env when no explicit backend", async () => {
|
|
41
|
+
process.env.OMCP_SCIM_BACKEND = "redis";
|
|
42
|
+
try {
|
|
43
|
+
const fake = {
|
|
44
|
+
_s: new Map(),
|
|
45
|
+
async get(k) { return this._s.get(k) ?? null; },
|
|
46
|
+
async set(k, v) { this._s.set(k, v); return "OK"; },
|
|
47
|
+
};
|
|
48
|
+
const store = await createScimStore({ redis: fake });
|
|
49
|
+
assert.ok(store instanceof RedisScimStore);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
delete process.env.OMCP_SCIM_BACKEND;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { applyPatchOps } from "./routes.js";
|
|
4
|
+
function group(members = []) {
|
|
5
|
+
return {
|
|
6
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
|
7
|
+
id: "g1",
|
|
8
|
+
displayName: "team",
|
|
9
|
+
members,
|
|
10
|
+
meta: { resourceType: "Group", created: "2026-06-06T00:00:00Z", lastModified: "2026-06-06T00:00:00Z" },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function patch(...ops) {
|
|
14
|
+
return { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: ops };
|
|
15
|
+
}
|
|
16
|
+
// --- replace (existing behaviour preserved) --------------------------
|
|
17
|
+
test("replace with no path merges allow-listed keys", () => {
|
|
18
|
+
const out = applyPatchOps(group(), patch({ op: "replace", value: { displayName: "renamed", externalId: "x" } }));
|
|
19
|
+
assert.equal(out.displayName, "renamed");
|
|
20
|
+
assert.equal(out.externalId, "x");
|
|
21
|
+
});
|
|
22
|
+
test("replace with path sets that attribute", () => {
|
|
23
|
+
const out = applyPatchOps(group(), patch({ op: "replace", path: "displayName", value: "n2" }));
|
|
24
|
+
assert.equal(out.displayName, "n2");
|
|
25
|
+
});
|
|
26
|
+
test("replace drops non-allowlisted keys (proto-pollution guard)", () => {
|
|
27
|
+
const out = applyPatchOps(group(), patch({ op: "replace", value: { __proto__: { polluted: true }, constructor: 1, displayName: "ok" } }));
|
|
28
|
+
assert.equal(out.displayName, "ok");
|
|
29
|
+
assert.equal({}.polluted, undefined);
|
|
30
|
+
assert.equal(Object.prototype.hasOwnProperty.call(out, "__proto__"), false);
|
|
31
|
+
assert.equal(Object.prototype.hasOwnProperty.call(out, "constructor"), false);
|
|
32
|
+
});
|
|
33
|
+
// --- add members -----------------------------------------------------
|
|
34
|
+
test("add appends a member to an empty group", () => {
|
|
35
|
+
const out = applyPatchOps(group(), patch({ op: "add", path: "members", value: [{ value: "u1", display: "Alice" }] }));
|
|
36
|
+
assert.deepEqual(out.members, [{ value: "u1", display: "Alice" }]);
|
|
37
|
+
});
|
|
38
|
+
test("add appends to existing members and dedups by value", () => {
|
|
39
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: [{ value: "u1" }, { value: "u2" }] }));
|
|
40
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
|
|
41
|
+
});
|
|
42
|
+
test("add accepts a single (non-array) value", () => {
|
|
43
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: { value: "u2" } }));
|
|
44
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
|
|
45
|
+
});
|
|
46
|
+
test("pathless add appends array attrs + sets scalars", () => {
|
|
47
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", value: { members: [{ value: "u2" }], displayName: "renamed" } }));
|
|
48
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u2"]);
|
|
49
|
+
assert.equal(out.displayName, "renamed");
|
|
50
|
+
});
|
|
51
|
+
// --- remove members --------------------------------------------------
|
|
52
|
+
test("remove with a filter drops the matching member", () => {
|
|
53
|
+
const out = applyPatchOps(group([{ value: "u1" }, { value: "u2" }, { value: "u3" }]), patch({ op: "remove", path: 'members[value eq "u2"]' }));
|
|
54
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1", "u3"]);
|
|
55
|
+
});
|
|
56
|
+
test("remove with a non-matching filter is a no-op on contents", () => {
|
|
57
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "remove", path: 'members[value eq "ghost"]' }));
|
|
58
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u1"]);
|
|
59
|
+
});
|
|
60
|
+
test("remove of the whole members attr clears it", () => {
|
|
61
|
+
const out = applyPatchOps(group([{ value: "u1" }, { value: "u2" }]), patch({ op: "remove", path: "members" }));
|
|
62
|
+
assert.deepEqual(out.members, []);
|
|
63
|
+
});
|
|
64
|
+
// --- chained ops in one request (Entra-style) ------------------------
|
|
65
|
+
test("add then remove in one request compose against the working value", () => {
|
|
66
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "add", path: "members", value: [{ value: "u2" }, { value: "u3" }] }, { op: "remove", path: 'members[value eq "u1"]' }));
|
|
67
|
+
assert.deepEqual(out.members.map((m) => m.value), ["u2", "u3"]);
|
|
68
|
+
});
|
|
69
|
+
// --- security: filtered paths can't pollute --------------------------
|
|
70
|
+
test("crafted __proto__ filter path is rejected (fail-closed no-op), no pollution", () => {
|
|
71
|
+
const out = applyPatchOps(group([{ value: "u1" }]), patch({ op: "remove", path: 'members[__proto__ eq "x"]' }));
|
|
72
|
+
// The sub-attribute regex requires a leading letter, so this path
|
|
73
|
+
// doesn't parse as a filter and isn't a bare allow-listed attr —
|
|
74
|
+
// the op is skipped entirely. No members key is emitted (no change)
|
|
75
|
+
// and nothing is polluted.
|
|
76
|
+
assert.equal(Object.prototype.hasOwnProperty.call(out, "members"), false);
|
|
77
|
+
assert.equal({}.x, undefined);
|
|
78
|
+
});
|
|
79
|
+
test("add to a non-allowlisted path is ignored", () => {
|
|
80
|
+
const out = applyPatchOps(group(), patch({ op: "add", path: "__proto__", value: { polluted: true } }));
|
|
81
|
+
assert.equal(Object.keys(out).length, 0);
|
|
82
|
+
assert.equal({}.polluted, undefined);
|
|
83
|
+
});
|
|
84
|
+
// --- emails array (same machinery) -----------------------------------
|
|
85
|
+
test("add + remove works on emails too", () => {
|
|
86
|
+
const user = {
|
|
87
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
88
|
+
id: "u1",
|
|
89
|
+
userName: "a@x",
|
|
90
|
+
emails: [{ value: "a@x", primary: true }],
|
|
91
|
+
meta: { resourceType: "User", created: "2026-06-06T00:00:00Z", lastModified: "2026-06-06T00:00:00Z" },
|
|
92
|
+
};
|
|
93
|
+
const added = applyPatchOps(user, patch({ op: "add", path: "emails", value: [{ value: "a2@x" }] }));
|
|
94
|
+
assert.deepEqual(added.emails.map((e) => e.value), ["a@x", "a2@x"]);
|
|
95
|
+
const removed = applyPatchOps(user, patch({ op: "remove", path: 'emails[value eq "a@x"]' }));
|
|
96
|
+
assert.deepEqual(removed.emails.map((e) => e.value), []);
|
|
97
|
+
});
|
|
98
|
+
test("missing target resource throws", () => {
|
|
99
|
+
assert.throws(() => applyPatchOps(undefined, patch({ op: "add", path: "members", value: [] })), /not found/);
|
|
100
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ScimGroup, type ScimUser } from "./types.js";
|
|
2
|
+
import { type IScimStore } from "./store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Redis surface we depend on. Matches both `ioredis` and the
|
|
5
|
+
* built-in promisified node-redis client — easier to swap clients
|
|
6
|
+
* and trivial to fake in unit tests.
|
|
7
|
+
*/
|
|
8
|
+
export interface RedisLike {
|
|
9
|
+
get(key: string): Promise<string | null>;
|
|
10
|
+
set(key: string, value: string): Promise<unknown>;
|
|
11
|
+
}
|
|
12
|
+
export declare class RedisScimStore implements IScimStore {
|
|
13
|
+
private readonly redis;
|
|
14
|
+
private readonly key;
|
|
15
|
+
private snapshot;
|
|
16
|
+
private bootstrapped;
|
|
17
|
+
private writeQueue;
|
|
18
|
+
constructor(redis: RedisLike, opts?: {
|
|
19
|
+
key?: string;
|
|
20
|
+
});
|
|
21
|
+
load(): Promise<void>;
|
|
22
|
+
listUsers(): ScimUser[];
|
|
23
|
+
getUser(id: string): ScimUser | undefined;
|
|
24
|
+
getUserByUserName(userName: string): ScimUser | undefined;
|
|
25
|
+
createUser(input: Partial<ScimUser>): Promise<ScimUser>;
|
|
26
|
+
updateUser(id: string, patch: Partial<ScimUser>): Promise<ScimUser>;
|
|
27
|
+
deleteUser(id: string): Promise<boolean>;
|
|
28
|
+
listGroups(): ScimGroup[];
|
|
29
|
+
getGroup(id: string): ScimGroup | undefined;
|
|
30
|
+
createGroup(input: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
31
|
+
updateGroup(id: string, patch: Partial<ScimGroup>): Promise<ScimGroup>;
|
|
32
|
+
deleteGroup(id: string): Promise<boolean>;
|
|
33
|
+
groupsContaining(userId: string): Array<{
|
|
34
|
+
value: string;
|
|
35
|
+
display?: string;
|
|
36
|
+
}>;
|
|
37
|
+
private persist;
|
|
38
|
+
}
|