@thotischner/observability-mcp 1.7.1 → 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.
- package/config/products.yaml.example +48 -0
- package/dist/audit/log.d.ts +99 -0
- package/dist/audit/log.js +180 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/auth/credentials.d.ts +18 -0
- package/dist/auth/credentials.js +26 -1
- package/dist/auth/credentials.test.js +26 -1
- package/dist/auth/local-users.d.ts +62 -0
- package/dist/auth/local-users.js +143 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +80 -0
- package/dist/auth/middleware.d.ts +48 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +124 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/runtime.d.ts +63 -0
- package/dist/auth/oidc/runtime.js +129 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +180 -0
- package/dist/auth/policy/engine.d.ts +48 -0
- package/dist/auth/policy/engine.js +73 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +35 -0
- package/dist/auth/policy/loader.js +100 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +162 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +158 -0
- package/dist/auth/rbac.d.ts +40 -0
- package/dist/auth/rbac.js +120 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +121 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/context.d.ts +13 -1
- package/dist/context.js +5 -1
- package/dist/index.js +1012 -29
- package/dist/net/egress-policy.js +2 -0
- package/dist/openapi.js +440 -0
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +64 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/products/loader.d.ts +84 -0
- package/dist/products/loader.js +216 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +168 -0
- package/dist/quota/limiter.d.ts +72 -0
- package/dist/quota/limiter.js +105 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +119 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/ui/index.html +1454 -88
- 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[];
|
package/dist/auth/credentials.js
CHANGED
|
@@ -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({
|
|
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
|
}
|