@thotischner/observability-mcp 1.7.0 → 1.8.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.
Files changed (111) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/audit/log.d.ts +99 -0
  3. package/dist/audit/log.js +180 -0
  4. package/dist/audit/log.test.d.ts +1 -0
  5. package/dist/audit/log.test.js +147 -0
  6. package/dist/audit/middleware.d.ts +20 -0
  7. package/dist/audit/middleware.js +50 -0
  8. package/dist/auth/credentials.d.ts +18 -0
  9. package/dist/auth/credentials.js +26 -1
  10. package/dist/auth/credentials.test.js +26 -1
  11. package/dist/auth/local-users.d.ts +62 -0
  12. package/dist/auth/local-users.js +143 -0
  13. package/dist/auth/local-users.test.d.ts +1 -0
  14. package/dist/auth/local-users.test.js +80 -0
  15. package/dist/auth/middleware.d.ts +48 -0
  16. package/dist/auth/middleware.js +65 -0
  17. package/dist/auth/middleware.test.d.ts +1 -0
  18. package/dist/auth/middleware.test.js +90 -0
  19. package/dist/auth/oidc/client.d.ts +73 -0
  20. package/dist/auth/oidc/client.js +104 -0
  21. package/dist/auth/oidc/client.test.d.ts +1 -0
  22. package/dist/auth/oidc/client.test.js +121 -0
  23. package/dist/auth/oidc/discovery.d.ts +38 -0
  24. package/dist/auth/oidc/discovery.js +48 -0
  25. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  26. package/dist/auth/oidc/discovery.test.js +68 -0
  27. package/dist/auth/oidc/endpoints.d.ts +20 -0
  28. package/dist/auth/oidc/endpoints.js +124 -0
  29. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  30. package/dist/auth/oidc/endpoints.test.js +304 -0
  31. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  32. package/dist/auth/oidc/flow-cookie.js +142 -0
  33. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  34. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  35. package/dist/auth/oidc/index.d.ts +7 -0
  36. package/dist/auth/oidc/index.js +6 -0
  37. package/dist/auth/oidc/jwks.d.ts +36 -0
  38. package/dist/auth/oidc/jwks.js +69 -0
  39. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  40. package/dist/auth/oidc/jwks.test.js +65 -0
  41. package/dist/auth/oidc/jwt.d.ts +62 -0
  42. package/dist/auth/oidc/jwt.js +113 -0
  43. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  44. package/dist/auth/oidc/jwt.test.js +141 -0
  45. package/dist/auth/oidc/pkce.d.ts +19 -0
  46. package/dist/auth/oidc/pkce.js +43 -0
  47. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  48. package/dist/auth/oidc/pkce.test.js +55 -0
  49. package/dist/auth/oidc/runtime.d.ts +63 -0
  50. package/dist/auth/oidc/runtime.js +129 -0
  51. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  52. package/dist/auth/oidc/runtime.test.js +180 -0
  53. package/dist/auth/policy/engine.d.ts +48 -0
  54. package/dist/auth/policy/engine.js +73 -0
  55. package/dist/auth/policy/engine.test.d.ts +1 -0
  56. package/dist/auth/policy/engine.test.js +98 -0
  57. package/dist/auth/policy/loader.d.ts +35 -0
  58. package/dist/auth/policy/loader.js +100 -0
  59. package/dist/auth/policy/opa.d.ts +69 -0
  60. package/dist/auth/policy/opa.js +162 -0
  61. package/dist/auth/policy/opa.test.d.ts +1 -0
  62. package/dist/auth/policy/opa.test.js +158 -0
  63. package/dist/auth/rbac.d.ts +40 -0
  64. package/dist/auth/rbac.js +120 -0
  65. package/dist/auth/rbac.test.d.ts +1 -0
  66. package/dist/auth/rbac.test.js +121 -0
  67. package/dist/auth/session.d.ts +66 -0
  68. package/dist/auth/session.js +146 -0
  69. package/dist/auth/session.test.d.ts +1 -0
  70. package/dist/auth/session.test.js +90 -0
  71. package/dist/catalog/loader.d.ts +67 -0
  72. package/dist/catalog/loader.js +122 -0
  73. package/dist/catalog/loader.test.d.ts +1 -0
  74. package/dist/catalog/loader.test.js +108 -0
  75. package/dist/connectors/kubernetes.d.ts +1 -0
  76. package/dist/connectors/kubernetes.js +12 -2
  77. package/dist/connectors/topology-vocabulary.d.ts +41 -0
  78. package/dist/connectors/topology-vocabulary.js +120 -0
  79. package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
  80. package/dist/connectors/topology-vocabulary.test.js +63 -0
  81. package/dist/context.d.ts +13 -1
  82. package/dist/context.js +5 -1
  83. package/dist/index.js +1012 -29
  84. package/dist/net/egress-policy.js +2 -0
  85. package/dist/openapi.js +440 -0
  86. package/dist/openapi.test.d.ts +1 -0
  87. package/dist/openapi.test.js +64 -0
  88. package/dist/policy/redact.d.ts +44 -0
  89. package/dist/policy/redact.js +144 -0
  90. package/dist/policy/redact.test.d.ts +1 -0
  91. package/dist/policy/redact.test.js +172 -0
  92. package/dist/products/loader.d.ts +84 -0
  93. package/dist/products/loader.js +216 -0
  94. package/dist/products/loader.test.d.ts +1 -0
  95. package/dist/products/loader.test.js +168 -0
  96. package/dist/quota/limiter.d.ts +72 -0
  97. package/dist/quota/limiter.js +105 -0
  98. package/dist/quota/limiter.test.d.ts +1 -0
  99. package/dist/quota/limiter.test.js +119 -0
  100. package/dist/quota/token-budget.d.ts +119 -0
  101. package/dist/quota/token-budget.js +297 -0
  102. package/dist/quota/token-budget.test.d.ts +1 -0
  103. package/dist/quota/token-budget.test.js +215 -0
  104. package/dist/tenancy/context.d.ts +45 -0
  105. package/dist/tenancy/context.js +97 -0
  106. package/dist/tenancy/context.test.d.ts +1 -0
  107. package/dist/tenancy/context.test.js +72 -0
  108. package/dist/tenancy/migration.test.d.ts +7 -0
  109. package/dist/tenancy/migration.test.js +75 -0
  110. package/dist/ui/index.html +1454 -88
  111. package/package.json +20 -3
@@ -0,0 +1,48 @@
1
+ # MCP Products catalog — copy to products.yaml + set OMCP_PRODUCTS_FILE
2
+ # to its path. See docs/products.md for the field reference.
3
+ #
4
+ # Every entry needs at minimum an id + name. Everything else is
5
+ # optional. The server validates this file at boot via the same
6
+ # parseProductsText pipeline the loader test suite exercises — a
7
+ # typo (e.g. `toolss:` instead of `tools:`) fails the boot loudly
8
+ # rather than silently dropping a grant.
9
+
10
+ products:
11
+ - id: ops-bundle
12
+ name: Operations Bundle
13
+ description: Incident-response tools for the on-call SRE agent.
14
+ tools:
15
+ - query_logs
16
+ - query_metrics
17
+ - get_service_health
18
+ version: "1.0.0"
19
+ status: published
20
+ branding:
21
+ iconUrl: https://example.com/icons/ops.svg
22
+ color: "#3178c6"
23
+
24
+ - id: dev-bundle
25
+ name: Developer Bundle
26
+ description: Discovery + topology tools for the coding agent. Read-only.
27
+ tools:
28
+ - list_services
29
+ - get_topology
30
+ status: published
31
+
32
+ - id: experimental-bundle
33
+ name: Experimental Bundle
34
+ description: New tools being trialled internally. Hidden from agents.
35
+ tools:
36
+ - query_logs
37
+ - get_topology
38
+ status: staging # admin-only until promoted
39
+
40
+ # Multi-tenant example: a product visible only to the "acme" tenant.
41
+ # Remove the tenant: line (or leave it unset) to land in "default".
42
+ - id: acme-compliance
43
+ name: Acme Compliance Read-Only
44
+ description: Logs query for Acme's compliance team agent.
45
+ tools:
46
+ - query_logs
47
+ status: published
48
+ tenant: acme
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Append-only audit log for the management plane.
3
+ *
4
+ * Distinct from the enterprise-gate audit (`enterprise/audit/`) which
5
+ * records every gated MCP tool call. This one records every mutating
6
+ * `/api/*` request (sources/settings/health-thresholds/connectors)
7
+ * so an operator can answer "who changed what, when".
8
+ *
9
+ * On-disk format is JSONL with one entry per line. Each line includes
10
+ * `prevHash` and `hash` (SHA-256 over the canonical JSON of the entry
11
+ * without the hash field itself, plus the previous hash) — a
12
+ * tamper-evident chain that `scripts/verify-audit.mjs` can walk.
13
+ *
14
+ * In-memory mode (no `OMCP_MGMT_AUDIT_FILE`) keeps the last
15
+ * `inMemoryCap` entries in a ring buffer so the UI's audit tab still
16
+ * shows something even without persistence configured. This is the
17
+ * default in the demo / single-user case.
18
+ */
19
+ export interface AuditEntry {
20
+ /** RFC-3339 UTC timestamp. */
21
+ ts: string;
22
+ /** Monotonically-increasing per-process sequence number. */
23
+ seq: number;
24
+ /** Identity that triggered the change. "anonymous" when auth is off. */
25
+ actor: {
26
+ sub: string;
27
+ name?: string;
28
+ };
29
+ /** Tenant the actor belonged to at the time of the action.
30
+ * Omitted on pre-E7 entries; readers default to "default". */
31
+ tenant?: string;
32
+ /** Logical resource + action, mirrors the RBAC vocabulary. */
33
+ resource: string;
34
+ action: string;
35
+ /** Full request method + path for easy reading in the UI. */
36
+ method: string;
37
+ path: string;
38
+ /** HTTP status code emitted by the gated handler. */
39
+ status: number;
40
+ /** Source IP, best-effort (honours X-Forwarded-For when trust-proxy is set). */
41
+ ip?: string;
42
+ /** Optional resource identifier extracted from the path (`:name` param). */
43
+ target?: string;
44
+ /** Tamper-evident chain. */
45
+ prevHash: string;
46
+ hash: string;
47
+ }
48
+ export interface AuditLogConfig {
49
+ /** Absolute path to a JSONL file. When undefined, the log lives in
50
+ * memory only. */
51
+ file?: string;
52
+ /** How many recent entries to keep in memory regardless of file mode. */
53
+ inMemoryCap?: number;
54
+ }
55
+ export declare const DEFAULT_IN_MEMORY_CAP = 500;
56
+ export declare class AuditLog {
57
+ private readonly cap;
58
+ private readonly file;
59
+ private ring;
60
+ private lastHash;
61
+ private seq;
62
+ private writeQueue;
63
+ private bootstrapped;
64
+ constructor(cfg?: AuditLogConfig);
65
+ /**
66
+ * If a file is configured, replay it to recover seq + lastHash so a
67
+ * server restart picks up the chain exactly where it left off.
68
+ * Safe to call multiple times — bootstraps once and caches.
69
+ */
70
+ bootstrap(): Promise<void>;
71
+ /**
72
+ * Record an event. Returns the canonical entry (with chain fields)
73
+ * once enqueued; persistence to disk completes asynchronously.
74
+ */
75
+ record(input: Omit<AuditEntry, "ts" | "seq" | "prevHash" | "hash">): Promise<AuditEntry>;
76
+ /** Snapshot of the in-memory ring (most recent last). */
77
+ list(opts?: {
78
+ from?: string;
79
+ to?: string;
80
+ actor?: string;
81
+ action?: string;
82
+ limit?: number;
83
+ tenant?: string;
84
+ }): AuditEntry[];
85
+ /** For verification scripts. */
86
+ get tipHash(): string;
87
+ get nextSeq(): number;
88
+ }
89
+ /** Stable hash of an entry-without-hash, against `prevHash` already present. */
90
+ export declare function chainHash(entryWithoutHash: Omit<AuditEntry, "hash">): string;
91
+ /** Walk a JSONL file end-to-end and confirm every entry's hash matches
92
+ * the chain. Used by the offline verifier CLI. */
93
+ export declare function verifyChain(entries: AuditEntry[]): {
94
+ ok: true;
95
+ } | {
96
+ ok: false;
97
+ brokenAt: number;
98
+ reason: string;
99
+ };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Append-only audit log for the management plane.
3
+ *
4
+ * Distinct from the enterprise-gate audit (`enterprise/audit/`) which
5
+ * records every gated MCP tool call. This one records every mutating
6
+ * `/api/*` request (sources/settings/health-thresholds/connectors)
7
+ * so an operator can answer "who changed what, when".
8
+ *
9
+ * On-disk format is JSONL with one entry per line. Each line includes
10
+ * `prevHash` and `hash` (SHA-256 over the canonical JSON of the entry
11
+ * without the hash field itself, plus the previous hash) — a
12
+ * tamper-evident chain that `scripts/verify-audit.mjs` can walk.
13
+ *
14
+ * In-memory mode (no `OMCP_MGMT_AUDIT_FILE`) keeps the last
15
+ * `inMemoryCap` entries in a ring buffer so the UI's audit tab still
16
+ * shows something even without persistence configured. This is the
17
+ * default in the demo / single-user case.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { appendFile, readFile } from "node:fs/promises";
21
+ export const DEFAULT_IN_MEMORY_CAP = 500;
22
+ const GENESIS_HASH = "0".repeat(64);
23
+ export class AuditLog {
24
+ cap;
25
+ file;
26
+ ring = [];
27
+ lastHash = GENESIS_HASH;
28
+ seq = 0;
29
+ writeQueue = Promise.resolve();
30
+ bootstrapped = null;
31
+ constructor(cfg = {}) {
32
+ this.cap = cfg.inMemoryCap ?? DEFAULT_IN_MEMORY_CAP;
33
+ this.file = cfg.file;
34
+ }
35
+ /**
36
+ * If a file is configured, replay it to recover seq + lastHash so a
37
+ * server restart picks up the chain exactly where it left off.
38
+ * Safe to call multiple times — bootstraps once and caches.
39
+ */
40
+ async bootstrap() {
41
+ if (!this.file)
42
+ return;
43
+ if (this.bootstrapped)
44
+ return this.bootstrapped;
45
+ this.bootstrapped = (async () => {
46
+ let raw;
47
+ try {
48
+ raw = await readFile(this.file, "utf8");
49
+ }
50
+ catch {
51
+ return; // first run, fine
52
+ }
53
+ for (const line of raw.split("\n")) {
54
+ const t = line.trim();
55
+ if (!t)
56
+ continue;
57
+ try {
58
+ const entry = JSON.parse(t);
59
+ if (typeof entry.seq === "number")
60
+ this.seq = Math.max(this.seq, entry.seq);
61
+ if (typeof entry.hash === "string")
62
+ this.lastHash = entry.hash;
63
+ if (this.ring.length === this.cap)
64
+ this.ring.shift();
65
+ this.ring.push(entry);
66
+ }
67
+ catch {
68
+ // skip malformed line — don't fail boot on a single corrupt entry
69
+ }
70
+ }
71
+ })();
72
+ return this.bootstrapped;
73
+ }
74
+ /**
75
+ * Record an event. Returns the canonical entry (with chain fields)
76
+ * once enqueued; persistence to disk completes asynchronously.
77
+ */
78
+ async record(input) {
79
+ if (this.bootstrapped)
80
+ await this.bootstrapped;
81
+ this.seq += 1;
82
+ const ts = new Date().toISOString();
83
+ const base = { ts, seq: this.seq, prevHash: this.lastHash, ...input };
84
+ const hash = chainHash(base);
85
+ const entry = { ...base, hash };
86
+ this.lastHash = hash;
87
+ if (this.ring.length === this.cap)
88
+ this.ring.shift();
89
+ this.ring.push(entry);
90
+ if (this.file) {
91
+ const file = this.file;
92
+ // Serialize disk writes so concurrent records don't interleave bytes.
93
+ this.writeQueue = this.writeQueue.then(() => appendFile(file, JSON.stringify(entry) + "\n", "utf8").catch(() => {
94
+ // intentionally swallow — losing a single audit line is
95
+ // strictly better than crashing the management plane.
96
+ }));
97
+ }
98
+ return entry;
99
+ }
100
+ /** Snapshot of the in-memory ring (most recent last). */
101
+ list(opts = {}) {
102
+ // Coerce non-finite / non-positive limits (NaN from a bad query
103
+ // string, negative, undefined) to the 100 default. Previously
104
+ // NaN propagated through Math.min / Math.max and the comparison
105
+ // `out.length < NaN` was always false, so `?limit=foo` returned
106
+ // an empty array instead of the default page.
107
+ const requested = typeof opts.limit === "number" && Number.isFinite(opts.limit) && opts.limit > 0
108
+ ? Math.floor(opts.limit)
109
+ : 100;
110
+ const lim = Math.min(requested, this.cap);
111
+ const out = [];
112
+ for (let i = this.ring.length - 1; i >= 0 && out.length < lim; i--) {
113
+ const e = this.ring[i];
114
+ if (opts.from && e.ts < opts.from)
115
+ continue;
116
+ if (opts.to && e.ts > opts.to)
117
+ continue;
118
+ if (opts.actor && e.actor.sub !== opts.actor)
119
+ continue;
120
+ if (opts.action && e.action !== opts.action)
121
+ continue;
122
+ if (opts.tenant) {
123
+ const entryTenant = e.tenant || "default";
124
+ if (entryTenant !== opts.tenant)
125
+ continue;
126
+ }
127
+ out.push(e);
128
+ }
129
+ return out;
130
+ }
131
+ /** For verification scripts. */
132
+ get tipHash() { return this.lastHash; }
133
+ get nextSeq() { return this.seq + 1; }
134
+ }
135
+ /** Stable hash of an entry-without-hash, against `prevHash` already present. */
136
+ export function chainHash(entryWithoutHash) {
137
+ // Canonical JSON: recursively sort keys so the digest is reproducible
138
+ // independent of insertion order. Cannot use JSON.stringify's
139
+ // string-array replacer here — that one filters at every level, which
140
+ // would strip nested properties (e.g. actor.sub) and collapse two
141
+ // distinct entries to the same canonical form.
142
+ return createHash("sha256").update(canonicalJson(entryWithoutHash)).digest("hex");
143
+ }
144
+ /** Deterministic JSON for hashing — keys sorted at every depth.
145
+ * Mirrors JSON.stringify's "drop undefined values" rule so a freshly-
146
+ * recorded entry and a round-tripped JSON.parse() of the same entry
147
+ * (which silently drops undefineds) hash identically. */
148
+ function canonicalJson(v) {
149
+ if (v === undefined)
150
+ return ""; // caller must filter at the parent level
151
+ if (v === null || typeof v !== "object")
152
+ return JSON.stringify(v);
153
+ if (Array.isArray(v))
154
+ return "[" + v.map((x) => canonicalJson(x ?? null)).join(",") + "]";
155
+ const o = v;
156
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
157
+ return "{" +
158
+ keys
159
+ .map((k) => JSON.stringify(k) + ":" + canonicalJson(o[k]))
160
+ .join(",") +
161
+ "}";
162
+ }
163
+ /** Walk a JSONL file end-to-end and confirm every entry's hash matches
164
+ * the chain. Used by the offline verifier CLI. */
165
+ export function verifyChain(entries) {
166
+ let prev = GENESIS_HASH;
167
+ for (let i = 0; i < entries.length; i++) {
168
+ const e = entries[i];
169
+ if (e.prevHash !== prev) {
170
+ return { ok: false, brokenAt: i, reason: `prevHash mismatch (expected ${prev}, got ${e.prevHash})` };
171
+ }
172
+ const { hash: _ignored, ...without } = e;
173
+ const expectedHash = chainHash(without);
174
+ if (e.hash !== expectedHash) {
175
+ return { ok: false, brokenAt: i, reason: `hash mismatch (expected ${expectedHash}, got ${e.hash})` };
176
+ }
177
+ prev = e.hash;
178
+ }
179
+ return { ok: true };
180
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,147 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { AuditLog, verifyChain, chainHash } from "./log.js";
7
+ function sample(seq, prevHash) {
8
+ const base = {
9
+ ts: `2026-05-28T00:00:${String(seq).padStart(2, "0")}Z`,
10
+ seq,
11
+ prevHash,
12
+ actor: { sub: "alice" },
13
+ resource: "sources",
14
+ action: "write",
15
+ method: "POST",
16
+ path: "/api/sources",
17
+ status: 200,
18
+ };
19
+ const hash = chainHash(base);
20
+ return { ...base, hash };
21
+ }
22
+ test("AuditLog in-memory — record + list", async () => {
23
+ const log = new AuditLog();
24
+ await log.record({
25
+ actor: { sub: "alice", name: "Alice" },
26
+ resource: "sources",
27
+ action: "write",
28
+ method: "POST",
29
+ path: "/api/sources",
30
+ status: 200,
31
+ });
32
+ await log.record({
33
+ actor: { sub: "bob" },
34
+ resource: "settings",
35
+ action: "write",
36
+ method: "PUT",
37
+ path: "/api/settings",
38
+ status: 200,
39
+ });
40
+ const entries = log.list();
41
+ assert.equal(entries.length, 2);
42
+ // Most recent first.
43
+ assert.equal(entries[0].actor.sub, "bob");
44
+ assert.equal(entries[1].actor.sub, "alice");
45
+ // Chain is intact: prevHash of entry 2 equals hash of entry 1
46
+ // (entries[1] is the FIRST chronologically here).
47
+ assert.equal(entries[0].prevHash, entries[1].hash);
48
+ });
49
+ test("AuditLog — list filters by actor + action + window", async () => {
50
+ const log = new AuditLog();
51
+ await log.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
52
+ await log.record({ actor: { sub: "bob" }, resource: "sources", action: "delete", method: "DELETE", path: "/api/sources/x", status: 200 });
53
+ await log.record({ actor: { sub: "alice" }, resource: "settings", action: "write", method: "PUT", path: "/api/settings", status: 200 });
54
+ assert.equal(log.list({ actor: "alice" }).length, 2);
55
+ assert.equal(log.list({ action: "delete" }).length, 1);
56
+ assert.equal(log.list({ actor: "bob", action: "delete" }).length, 1);
57
+ // Empty window → nothing
58
+ assert.equal(log.list({ from: "2099-01-01", to: "2099-12-31" }).length, 0);
59
+ });
60
+ test("AuditLog — tenant filter scopes results; entries with no tenant treated as 'default'", async () => {
61
+ const log = new AuditLog();
62
+ await log.record({ actor: { sub: "alice" }, tenant: "acme", resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
63
+ await log.record({ actor: { sub: "bob" }, tenant: "bigco", resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
64
+ // No tenant on this entry → treated as "default" when filtering
65
+ await log.record({ actor: { sub: "carol" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
66
+ assert.equal(log.list({ tenant: "acme" }).length, 1);
67
+ assert.equal(log.list({ tenant: "bigco" }).length, 1);
68
+ assert.equal(log.list({ tenant: "default" }).length, 1);
69
+ // No tenant filter → all three visible
70
+ assert.equal(log.list({}).length, 3);
71
+ });
72
+ test("AuditLog — in-memory ring honours cap", async () => {
73
+ const log = new AuditLog({ inMemoryCap: 3 });
74
+ for (let i = 0; i < 10; i++) {
75
+ await log.record({ actor: { sub: `u${i}` }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
76
+ }
77
+ const entries = log.list({ limit: 100 });
78
+ assert.equal(entries.length, 3);
79
+ // Only the three most recent should survive.
80
+ assert.deepEqual(entries.map((e) => e.actor.sub), ["u9", "u8", "u7"]);
81
+ });
82
+ test("AuditLog.list — non-finite / non-positive limit falls back to the 100 default", async () => {
83
+ const log = new AuditLog({ inMemoryCap: 10 });
84
+ for (let i = 0; i < 5; i++) {
85
+ await log.record({ actor: { sub: `u${i}` }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
86
+ }
87
+ // NaN — what /api/audit?limit=foo previously produced via parseInt
88
+ assert.equal(log.list({ limit: Number.NaN }).length, 5);
89
+ // Negative — no point pretending the caller meant "minus three entries"
90
+ assert.equal(log.list({ limit: -3 }).length, 5);
91
+ // Zero — same. The default keeps the response useful.
92
+ assert.equal(log.list({ limit: 0 }).length, 5);
93
+ // Sanity: a sensible positive limit still constrains the result
94
+ assert.equal(log.list({ limit: 2 }).length, 2);
95
+ // Decimal positive — floored, so 2.9 → 2
96
+ assert.equal(log.list({ limit: 2.9 }).length, 2);
97
+ });
98
+ test("AuditLog — file mode persists and bootstraps", async () => {
99
+ const dir = await mkdtemp(join(tmpdir(), "omcp-audit-"));
100
+ const file = join(dir, "audit.jsonl");
101
+ try {
102
+ const log1 = new AuditLog({ file });
103
+ await log1.bootstrap();
104
+ await log1.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
105
+ await log1.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
106
+ // Allow the write queue to flush.
107
+ await new Promise((r) => setTimeout(r, 30));
108
+ const raw = await readFile(file, "utf8");
109
+ const lines = raw.trim().split("\n");
110
+ assert.equal(lines.length, 2);
111
+ // Restart: bootstrap should pick up seq + lastHash.
112
+ const log2 = new AuditLog({ file });
113
+ await log2.bootstrap();
114
+ assert.equal(log2.nextSeq, 3, "expected nextSeq to resume at 3 after replay");
115
+ const next = await log2.record({ actor: { sub: "alice" }, resource: "sources", action: "write", method: "POST", path: "/api/sources", status: 200 });
116
+ assert.equal(next.seq, 3);
117
+ // prevHash chain continued from the replayed tip.
118
+ const firstChain = JSON.parse(lines[0]);
119
+ const secondChain = JSON.parse(lines[1]);
120
+ assert.equal(secondChain.prevHash, firstChain.hash);
121
+ assert.equal(next.prevHash, secondChain.hash);
122
+ }
123
+ finally {
124
+ await rm(dir, { recursive: true, force: true });
125
+ }
126
+ });
127
+ test("verifyChain — accepts a clean chain", () => {
128
+ const e1 = sample(1, "0".repeat(64));
129
+ const e2 = sample(2, e1.hash);
130
+ const r = verifyChain([e1, e2]);
131
+ assert.deepEqual(r, { ok: true });
132
+ });
133
+ test("verifyChain — rejects a flipped prevHash", () => {
134
+ const e1 = sample(1, "0".repeat(64));
135
+ const e2 = { ...sample(2, e1.hash), prevHash: "deadbeef".repeat(8) };
136
+ const r = verifyChain([e1, e2]);
137
+ assert.equal(r.ok, false);
138
+ if (!r.ok)
139
+ assert.equal(r.brokenAt, 1);
140
+ });
141
+ test("verifyChain — rejects an entry whose hash doesn't match its content", () => {
142
+ const e1 = sample(1, "0".repeat(64));
143
+ // Tamper with `actor` AFTER hashing.
144
+ const e1Tampered = { ...e1, actor: { sub: "mallory" } };
145
+ const r = verifyChain([e1Tampered]);
146
+ assert.equal(r.ok, false);
147
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Express middleware that records one audit entry per mutating
3
+ * /api/* request after the response has been sent. Skips read-only
4
+ * methods. In anonymous mode the actor is reported as
5
+ * `anonymous`; in basic mode the session's sub/name are used.
6
+ */
7
+ import type { RequestHandler } from "express";
8
+ import { AuditLog } from "./log.js";
9
+ export interface AuditMiddlewareConfig {
10
+ audit: AuditLog;
11
+ resource: string;
12
+ action: string;
13
+ }
14
+ /**
15
+ * Build a per-route audit middleware. Pairs cleanly with the RBAC
16
+ * `need(resource, action)` calls already on the route — pass the same
17
+ * (resource, action) pair so the audit entry matches the policy
18
+ * decision that just ran.
19
+ */
20
+ export declare function buildAuditMiddleware(cfg: AuditMiddlewareConfig): RequestHandler;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Express middleware that records one audit entry per mutating
3
+ * /api/* request after the response has been sent. Skips read-only
4
+ * methods. In anonymous mode the actor is reported as
5
+ * `anonymous`; in basic mode the session's sub/name are used.
6
+ */
7
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
8
+ /**
9
+ * Build a per-route audit middleware. Pairs cleanly with the RBAC
10
+ * `need(resource, action)` calls already on the route — pass the same
11
+ * (resource, action) pair so the audit entry matches the policy
12
+ * decision that just ran.
13
+ */
14
+ export function buildAuditMiddleware(cfg) {
15
+ return function audit(req, res, next) {
16
+ if (!MUTATING.has(req.method)) {
17
+ next();
18
+ return;
19
+ }
20
+ res.on("finish", () => {
21
+ const sess = req.session;
22
+ // Pick the most-likely identifier from the route params so the
23
+ // audit entry's `target` lines up with what the operator
24
+ // typed. Most routes use `:name`; products use `:id`; fall
25
+ // through if neither (the entry still records method+path).
26
+ const target = typeof req.params?.name === "string"
27
+ ? req.params.name
28
+ : typeof req.params?.id === "string" ? req.params.id : undefined;
29
+ cfg.audit
30
+ .record({
31
+ actor: sess
32
+ ? { sub: sess.sub, name: sess.name }
33
+ : { sub: "anonymous" },
34
+ tenant: sess?.tenant || "default",
35
+ resource: cfg.resource,
36
+ action: cfg.action,
37
+ method: req.method,
38
+ path: req.path,
39
+ status: res.statusCode,
40
+ ip: req.ip || undefined,
41
+ target,
42
+ })
43
+ .catch(() => {
44
+ // record() already swallows file errors — this catch only
45
+ // covers the synchronous Promise wiring.
46
+ });
47
+ });
48
+ next();
49
+ };
50
+ }
@@ -10,6 +10,17 @@
10
10
  * (a bare "tok_xyz" is allowed; name defaults to "key")
11
11
  * OMCP_KEY_SOURCES="agent=prom-prod|loki-prod;ci=prom-staging"
12
12
  * # optional coarse per-key source allow-list
13
+ * OMCP_KEY_BYPASS_REDACTION="agent,ci"
14
+ * # optional comma-separated list of key NAMES allowed
15
+ * # to bypass log-payload redaction on per-call request
16
+ * # via the bypass_redaction tool arg. Off by default
17
+ * # for every key — pair with the redaction:bypass
18
+ * # RBAC permission for the management-plane angle.
19
+ * OMCP_KEY_TENANTS="agent=acme;ci=bigco"
20
+ * # optional per-key tenant assignment. Unlisted keys
21
+ * # land in the "default" tenant — identical to the
22
+ * # pre-E7 single-namespace world. See docs/tenancy.md
23
+ * # (slice 5) for the cross-cutting model.
13
24
  *
14
25
  * Rich role-based access control (tools/services/lookback/read-only, the
15
26
  * full governance object) is intentionally NOT here — this is only the
@@ -19,6 +30,13 @@ export interface Credential {
19
30
  name: string;
20
31
  token: string;
21
32
  allowedSources?: string[];
33
+ /** True when the operator opted this credential into the per-call
34
+ * redaction bypass. The bypass still requires the MCP tool caller
35
+ * to explicitly set `bypass_redaction: true` in the tool args —
36
+ * this flag only authorises it; it never auto-disables redaction. */
37
+ bypassRedaction?: boolean;
38
+ /** Tenant this credential belongs to. Omitted → DEFAULT_TENANT. */
39
+ tenant?: string;
22
40
  }
23
41
  /** Parse credentials from env. Returns an empty list when unconfigured. */
24
42
  export declare function loadCredentials(env?: NodeJS.ProcessEnv): Credential[];
@@ -10,11 +10,23 @@
10
10
  * (a bare "tok_xyz" is allowed; name defaults to "key")
11
11
  * OMCP_KEY_SOURCES="agent=prom-prod|loki-prod;ci=prom-staging"
12
12
  * # optional coarse per-key source allow-list
13
+ * OMCP_KEY_BYPASS_REDACTION="agent,ci"
14
+ * # optional comma-separated list of key NAMES allowed
15
+ * # to bypass log-payload redaction on per-call request
16
+ * # via the bypass_redaction tool arg. Off by default
17
+ * # for every key — pair with the redaction:bypass
18
+ * # RBAC permission for the management-plane angle.
19
+ * OMCP_KEY_TENANTS="agent=acme;ci=bigco"
20
+ * # optional per-key tenant assignment. Unlisted keys
21
+ * # land in the "default" tenant — identical to the
22
+ * # pre-E7 single-namespace world. See docs/tenancy.md
23
+ * # (slice 5) for the cross-cutting model.
13
24
  *
14
25
  * Rich role-based access control (tools/services/lookback/read-only, the
15
26
  * full governance object) is intentionally NOT here — this is only the
16
27
  * authentication + identity + coarse source-scoping primitive.
17
28
  */
29
+ import { parseKeyTenants } from "../tenancy/context.js";
18
30
  function parseKeySources(raw) {
19
31
  const m = new Map();
20
32
  if (!raw)
@@ -27,12 +39,19 @@ function parseKeySources(raw) {
27
39
  }
28
40
  return m;
29
41
  }
42
+ function parseBypassSet(raw) {
43
+ if (!raw)
44
+ return new Set();
45
+ return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
46
+ }
30
47
  /** Parse credentials from env. Returns an empty list when unconfigured. */
31
48
  export function loadCredentials(env = process.env) {
32
49
  const raw = env.OMCP_API_KEYS?.trim();
33
50
  if (!raw)
34
51
  return [];
35
52
  const keySources = parseKeySources(env.OMCP_KEY_SOURCES);
53
+ const bypassNames = parseBypassSet(env.OMCP_KEY_BYPASS_REDACTION);
54
+ const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
36
55
  const creds = [];
37
56
  for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
38
57
  const idx = part.indexOf(":");
@@ -40,7 +59,13 @@ export function loadCredentials(env = process.env) {
40
59
  const token = (idx > 0 ? part.slice(idx + 1) : part).trim();
41
60
  if (!token)
42
61
  continue;
43
- creds.push({ name, token, allowedSources: keySources.get(name) });
62
+ creds.push({
63
+ name,
64
+ token,
65
+ allowedSources: keySources.get(name),
66
+ bypassRedaction: bypassNames.has(name) || undefined,
67
+ tenant: keyTenants.get(name) || undefined,
68
+ });
44
69
  }
45
70
  return creds;
46
71
  }