@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
package/dist/metrics/self.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const mcpToolLatency: Histogram<"tool">;
|
|
|
5
5
|
export declare const connectorCalls: Counter<"type" | "source" | "outcome" | "operation">;
|
|
6
6
|
export declare const apiRequests: Counter<"status" | "route" | "method">;
|
|
7
7
|
export declare const mcpActiveSessions: Gauge<string>;
|
|
8
|
+
export declare const auditDlqDepth: Gauge<string>;
|
|
8
9
|
/**
|
|
9
10
|
* Wrap a (potentially async) tool handler to record call count + latency.
|
|
10
11
|
* Outcome is "ok" or "error" — never throws on its own.
|
package/dist/metrics/self.js
CHANGED
|
@@ -40,6 +40,14 @@ export const mcpActiveSessions = new Gauge({
|
|
|
40
40
|
help: "Active MCP Streamable HTTP sessions.",
|
|
41
41
|
registers: [selfRegistry],
|
|
42
42
|
});
|
|
43
|
+
// P9: Audit webhook dead-letter queue depth. Refreshed on each
|
|
44
|
+
// `/metrics` scrape and when the operator hits `/api/audit/dlq`.
|
|
45
|
+
// Stays at 0 when no DLQ file is configured or the file is missing.
|
|
46
|
+
export const auditDlqDepth = new Gauge({
|
|
47
|
+
name: "obsmcp_audit_webhook_dlq_depth",
|
|
48
|
+
help: "Number of audit entries waiting in the webhook-sink dead-letter queue.",
|
|
49
|
+
registers: [selfRegistry],
|
|
50
|
+
});
|
|
43
51
|
/**
|
|
44
52
|
* Wrap a (potentially async) tool handler to record call count + latency.
|
|
45
53
|
* Outcome is "ok" or "error" — never throws on its own.
|
package/dist/openapi.js
CHANGED
|
@@ -394,6 +394,45 @@ export function buildOpenApiSpec(version) {
|
|
|
394
394
|
responses: { "204": { description: "Cookie cleared." } },
|
|
395
395
|
},
|
|
396
396
|
},
|
|
397
|
+
"/api/auth/revocations": {
|
|
398
|
+
get: {
|
|
399
|
+
tags: ["auth"],
|
|
400
|
+
summary: "List session revocations (admin).",
|
|
401
|
+
description: "Returns the current revocation blocklist. Admin-gated (users:delete). Empty in anonymous mode.",
|
|
402
|
+
responses: {
|
|
403
|
+
"200": { description: "Array of revocation entries." },
|
|
404
|
+
"401": { description: "Authentication required." },
|
|
405
|
+
"403": { description: "Caller lacks the admin permission." },
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
post: {
|
|
409
|
+
tags: ["auth"],
|
|
410
|
+
summary: "Revoke a session or all of a subject's sessions (admin).",
|
|
411
|
+
description: "Adds an entry to the on-disk blocklist. Provide exactly one of `sid` (revoke one session — copy it from /api/me) or `sub` (log a user out everywhere — revokes every session issued so far; a fresh login afterwards is unaffected). The next request bearing a revoked cookie is treated as logged out.",
|
|
412
|
+
requestBody: {
|
|
413
|
+
required: true,
|
|
414
|
+
content: {
|
|
415
|
+
"application/json": {
|
|
416
|
+
schema: {
|
|
417
|
+
type: "object",
|
|
418
|
+
properties: {
|
|
419
|
+
sid: { type: "string", description: "Session id to revoke (from /api/me)." },
|
|
420
|
+
sub: { type: "string", description: "Subject whose current sessions to revoke." },
|
|
421
|
+
reason: { type: "string", description: "Optional free-text reason (truncated to 500 chars)." },
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
responses: {
|
|
428
|
+
"201": { description: "Revocation recorded; the entry is returned." },
|
|
429
|
+
"400": { description: "Neither or both of sid/sub supplied." },
|
|
430
|
+
"401": { description: "Authentication required." },
|
|
431
|
+
"403": { description: "Caller lacks the admin permission." },
|
|
432
|
+
"503": { description: "Server is in anonymous mode (no sessions to revoke)." },
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
397
436
|
"/api/auth/oidc/login": {
|
|
398
437
|
get: {
|
|
399
438
|
tags: ["auth"],
|
package/dist/openapi.test.js
CHANGED
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 {};
|