@thotischner/observability-mcp 3.6.1 → 3.8.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.
Files changed (61) hide show
  1. package/dist/auth/policy/loader.js +1 -1
  2. package/dist/auth/rbac.d.ts +1 -1
  3. package/dist/auth/rbac.js +3 -1
  4. package/dist/auth/rbac.test.js +5 -3
  5. package/dist/conformance/inspect-e2e.test.d.ts +1 -0
  6. package/dist/conformance/inspect-e2e.test.js +104 -0
  7. package/dist/connectors/loader.js +30 -0
  8. package/dist/connectors/loader.test.js +11 -0
  9. package/dist/enrich/rdap.d.ts +40 -0
  10. package/dist/enrich/rdap.js +122 -0
  11. package/dist/enrich/rdap.test.d.ts +1 -0
  12. package/dist/enrich/rdap.test.js +78 -0
  13. package/dist/enterprise-gate.d.ts +28 -0
  14. package/dist/enterprise-gate.js +51 -0
  15. package/dist/enterprise-gate.test.js +21 -1
  16. package/dist/index.js +296 -8
  17. package/dist/inspect/enforcer.d.ts +19 -0
  18. package/dist/inspect/enforcer.js +69 -0
  19. package/dist/inspect/enforcer.test.d.ts +1 -0
  20. package/dist/inspect/enforcer.test.js +76 -0
  21. package/dist/inspect/graph.d.ts +33 -0
  22. package/dist/inspect/graph.js +0 -0
  23. package/dist/inspect/graph.test.d.ts +1 -0
  24. package/dist/inspect/graph.test.js +74 -0
  25. package/dist/inspect/index.d.ts +8 -0
  26. package/dist/inspect/index.js +13 -0
  27. package/dist/inspect/mode.d.ts +20 -0
  28. package/dist/inspect/mode.js +57 -0
  29. package/dist/inspect/mode.test.d.ts +1 -0
  30. package/dist/inspect/mode.test.js +53 -0
  31. package/dist/inspect/profile-store.d.ts +42 -0
  32. package/dist/inspect/profile-store.js +139 -0
  33. package/dist/inspect/profile-store.test.d.ts +1 -0
  34. package/dist/inspect/profile-store.test.js +82 -0
  35. package/dist/inspect/profile.d.ts +51 -0
  36. package/dist/inspect/profile.js +111 -0
  37. package/dist/inspect/profile.test.d.ts +1 -0
  38. package/dist/inspect/profile.test.js +96 -0
  39. package/dist/inspect/recorder.d.ts +42 -0
  40. package/dist/inspect/recorder.js +72 -0
  41. package/dist/inspect/recorder.test.d.ts +1 -0
  42. package/dist/inspect/recorder.test.js +112 -0
  43. package/dist/inspect/signature.d.ts +32 -0
  44. package/dist/inspect/signature.js +200 -0
  45. package/dist/inspect/signature.test.d.ts +1 -0
  46. package/dist/inspect/signature.test.js +136 -0
  47. package/dist/inspect/store.d.ts +62 -0
  48. package/dist/inspect/store.js +76 -0
  49. package/dist/inspect/store.test.d.ts +1 -0
  50. package/dist/inspect/store.test.js +78 -0
  51. package/dist/metrics/self.d.ts +3 -0
  52. package/dist/metrics/self.js +19 -0
  53. package/dist/net/egress-policy.js +1 -0
  54. package/dist/tenancy/context.d.ts +7 -0
  55. package/dist/tenancy/context.js +15 -0
  56. package/dist/tenancy/context.test.js +18 -1
  57. package/dist/tools/enrich-ips.d.ts +6 -2
  58. package/dist/tools/enrich-ips.js +32 -11
  59. package/dist/tools/enrich-ips.test.js +55 -11
  60. package/dist/ui/index.html +742 -0
  61. package/package.json +3 -2
@@ -0,0 +1,136 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { deriveSignature, countBucket, durationToSeconds, durationBucket, numBucket, queryFingerprint, } from "./signature.js";
4
+ describe("buckets", () => {
5
+ it("countBucket bands", () => {
6
+ assert.equal(countBucket(0), "1");
7
+ assert.equal(countBucket(1), "1");
8
+ assert.equal(countBucket(7), "2-10");
9
+ assert.equal(countBucket(50), "11-100");
10
+ assert.equal(countBucket(900), "101-1000");
11
+ assert.equal(countBucket(5000), ">1000");
12
+ });
13
+ it("durationToSeconds parses prom/loki durations", () => {
14
+ assert.equal(durationToSeconds("5m"), 300);
15
+ assert.equal(durationToSeconds("1h"), 3600);
16
+ assert.equal(durationToSeconds("1h30m"), 5400);
17
+ assert.equal(durationToSeconds("2d"), 172800);
18
+ assert.equal(durationToSeconds("250ms"), 0.25);
19
+ });
20
+ it("durationToSeconds rejects non-durations", () => {
21
+ assert.equal(durationToSeconds("not-a-duration"), null);
22
+ assert.equal(durationToSeconds("100"), null);
23
+ assert.equal(durationToSeconds(""), null);
24
+ });
25
+ it("durationBucket bands + 'other' for junk", () => {
26
+ assert.equal(durationBucket("30s"), "<=5m");
27
+ assert.equal(durationBucket("45m"), "<=1h");
28
+ assert.equal(durationBucket("12h"), "<=1d");
29
+ assert.equal(durationBucket("7d"), ">1d");
30
+ assert.equal(durationBucket("garbage"), "other");
31
+ });
32
+ it("numBucket bands", () => {
33
+ assert.equal(numBucket(5), "<=10");
34
+ assert.equal(numBucket(50), "<=100");
35
+ assert.equal(numBucket(500), "<=1000");
36
+ assert.equal(numBucket(9999), ">1000");
37
+ });
38
+ });
39
+ describe("queryFingerprint", () => {
40
+ it("blank → empty", () => {
41
+ assert.equal(queryFingerprint(""), "empty");
42
+ assert.equal(queryFingerprint(" "), "empty");
43
+ });
44
+ it("extracts func + metric + label from a PromQL rate query", () => {
45
+ const fp = queryFingerprint('rate(http_requests_total{job="api"}[5m])');
46
+ assert.match(fp, /f:rate/);
47
+ assert.match(fp, /m:http_requests_total/);
48
+ assert.match(fp, /l:job/);
49
+ // the range fragment "m" and the quoted value "api" are NOT metrics
50
+ assert.ok(!/[, ]m[, ]|m:.*\bm\b/.test(fp.replace("http_requests_total", "")));
51
+ assert.ok(!fp.includes("api"));
52
+ });
53
+ it("treats by()-grouping labels as labels, not metrics", () => {
54
+ const fp = queryFingerprint("sum by (instance) (up)");
55
+ assert.match(fp, /f:sum/);
56
+ assert.match(fp, /m:up/);
57
+ assert.match(fp, /l:instance/);
58
+ });
59
+ it("LogQL stream selector → label keys", () => {
60
+ const fp = queryFingerprint('{service_name="payment", level="error"} |= "boom"');
61
+ assert.match(fp, /l:level,service_name/);
62
+ assert.ok(!fp.includes("payment") && !fp.includes("boom"));
63
+ });
64
+ it("is deterministic + sorted + capped", () => {
65
+ const a = queryFingerprint('sum(rate(a_total[1m])) + avg(b_total)');
66
+ const b = queryFingerprint('avg(b_total) + sum(rate(a_total[1m]))');
67
+ assert.equal(a, b);
68
+ assert.match(a, /f:avg,rate,sum/);
69
+ assert.match(a, /m:a_total,b_total/);
70
+ });
71
+ it("is fast on a huge attacker-supplied query (no ReDoS)", () => {
72
+ const t0 = Date.now();
73
+ queryFingerprint("a".repeat(100000));
74
+ queryFingerprint("(".repeat(50000));
75
+ queryFingerprint("rate(".repeat(20000));
76
+ assert.ok(Date.now() - t0 < 100, "fingerprint stays linear/bounded on large input");
77
+ });
78
+ it("non-query free text with no structure → present", () => {
79
+ assert.equal(queryFingerprint("just some words"), "m:just,some,words"); // bare idents count as metrics
80
+ assert.equal(queryFingerprint("!!!"), "present");
81
+ });
82
+ });
83
+ describe("deriveSignature query fingerprinting", () => {
84
+ it("query/expr keys get a fingerprint, not 'present'", () => {
85
+ const sig = deriveSignature("query_metrics", { service: "pay", query: 'rate(http_requests_total[5m])' });
86
+ assert.match(sig.argShape.query, /m:http_requests_total/);
87
+ assert.match(sig.argShape.query, /f:rate/);
88
+ });
89
+ it("non-query free text still collapses to 'present'", () => {
90
+ const sig = deriveSignature("x", { note: "hello world" });
91
+ assert.equal(sig.argShape.note, "present");
92
+ });
93
+ });
94
+ describe("deriveSignature", () => {
95
+ it("lifts source/service/namespace as real resource dimensions", () => {
96
+ const sig = deriveSignature("query_logs", { source: "prom-eu", service: "payment", namespace: "omcp" });
97
+ assert.equal(sig.source, "prom-eu");
98
+ assert.equal(sig.service, "payment");
99
+ assert.equal(sig.namespace, "omcp");
100
+ });
101
+ it("buckets array length, never keeps elements", () => {
102
+ const sig = deriveSignature("enrich_ips", { ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"] });
103
+ assert.equal(sig.argShape.ips, "n=2-10");
104
+ assert.ok(!("source" in sig));
105
+ });
106
+ it("buckets duration-shaped keys, fingerprints queries (literal never kept)", () => {
107
+ const sig = deriveSignature("query_metrics", { query: "rate(http_requests_total[5m])", window: "1h", limit: 500 });
108
+ assert.match(sig.argShape.query, /m:http_requests_total/); // structural, not the literal
109
+ assert.ok(!sig.argShape.query.includes("[5m]"));
110
+ assert.equal(sig.argShape.window, "<=1h");
111
+ assert.equal(sig.argShape.limit, "<=1000");
112
+ });
113
+ it("handles booleans, empty strings, nested objects, and non-objects", () => {
114
+ const sig = deriveSignature("x", { flag: true, empty: "", nested: { a: 1 } });
115
+ assert.equal(sig.argShape.flag, "true");
116
+ assert.equal(sig.argShape.empty, "empty");
117
+ assert.equal(sig.argShape.nested, "object");
118
+ assert.deepEqual(deriveSignature("x", null), { argShape: {} });
119
+ assert.deepEqual(deriveSignature("x", "str"), { argShape: {} });
120
+ });
121
+ it("drops prototype-polluting arg keys (__proto__/constructor/prototype)", () => {
122
+ const sig = deriveSignature("x", JSON.parse('{"__proto__":"p","constructor":"c","prototype":"q","ok":5}'));
123
+ const keys = Object.keys(sig.argShape);
124
+ assert.ok(!keys.includes("__proto__"));
125
+ assert.ok(!keys.includes("constructor"));
126
+ assert.ok(!keys.includes("prototype"));
127
+ assert.equal(sig.argShape.ok, "<=10");
128
+ // prototype not polluted
129
+ assert.equal({}.p, undefined);
130
+ });
131
+ it("is deterministic", () => {
132
+ const a = deriveSignature("query_logs", { source: "s", service: "v", limit: 5, window: "5m" });
133
+ const b = deriveSignature("query_logs", { window: "5m", limit: 5, service: "v", source: "s" });
134
+ assert.deepEqual(a, b);
135
+ });
136
+ });
@@ -0,0 +1,62 @@
1
+ export type Outcome = "ok" | "error";
2
+ export type Decision = "allow" | "would-block" | "blocked";
3
+ /** A single recorded tool-call observation (a signature, never raw args). */
4
+ export interface Observation {
5
+ ts: string;
6
+ seq: number;
7
+ principal: string;
8
+ auth: string;
9
+ tenant: string;
10
+ tool: string;
11
+ source?: string;
12
+ service?: string;
13
+ namespace?: string;
14
+ argShape: Record<string, string>;
15
+ outcome: Outcome;
16
+ decision: Decision;
17
+ /** Deviation kind when the call fell outside the profile (dry-run/enforce). */
18
+ deviation?: string;
19
+ /** How many PII matches the redactor stripped from the args before shaping. */
20
+ redactions: number;
21
+ }
22
+ export type ObservationInput = Omit<Observation, "ts" | "seq"> & {
23
+ ts?: string;
24
+ };
25
+ export interface QueryOpts {
26
+ from?: string;
27
+ to?: string;
28
+ principal?: string;
29
+ tool?: string;
30
+ outcome?: Outcome;
31
+ decision?: Decision;
32
+ limit?: number;
33
+ }
34
+ export interface InspectStoreOptions {
35
+ /** JSONL mirror path. When unset, the store is memory-only. */
36
+ file?: string;
37
+ /** Ring-buffer capacity (default 5000). */
38
+ cap?: number;
39
+ /** Clock seam for tests. */
40
+ now?: () => number;
41
+ /** Best-effort async appender seam for tests (defaults to fs append). */
42
+ appender?: (file: string, line: string) => Promise<void>;
43
+ }
44
+ export declare class InspectStore {
45
+ private ring;
46
+ private readonly cap;
47
+ private readonly file?;
48
+ private readonly now;
49
+ private readonly appender;
50
+ private seqCounter;
51
+ constructor(opts?: InspectStoreOptions);
52
+ /** Record one observation. Never throws. */
53
+ record(input: ObservationInput): Observation;
54
+ /** Newest-first query with optional filters. */
55
+ list(opts?: QueryOpts): Observation[];
56
+ /** All observations at or after `sinceMs` (epoch millis), oldest-first. */
57
+ since(sinceMs: number): Observation[];
58
+ /** Every observation currently retained (oldest-first). */
59
+ all(): Observation[];
60
+ get size(): number;
61
+ get persisted(): boolean;
62
+ }
@@ -0,0 +1,76 @@
1
+ // Inspect — observation store.
2
+ //
3
+ // Mirrors the audit-log storage pattern: an in-memory ring buffer (always on)
4
+ // plus an optional append-only JSONL mirror for durability. The ring is the
5
+ // query surface; the file is a write-only durable copy. Writes are best-effort
6
+ // and never throw — observation must never be able to slow down or fail a tool
7
+ // call.
8
+ import { appendFile } from "node:fs/promises";
9
+ const DEFAULT_CAP = 5000;
10
+ export class InspectStore {
11
+ ring = [];
12
+ cap;
13
+ file;
14
+ now;
15
+ appender;
16
+ seqCounter = 0;
17
+ constructor(opts = {}) {
18
+ this.cap = opts.cap && opts.cap > 0 ? opts.cap : DEFAULT_CAP;
19
+ this.file = opts.file;
20
+ this.now = opts.now ?? (() => Date.now());
21
+ this.appender = opts.appender ?? ((f, line) => appendFile(f, line));
22
+ }
23
+ /** Record one observation. Never throws. */
24
+ record(input) {
25
+ const obs = {
26
+ ...input,
27
+ ts: input.ts ?? new Date(this.now()).toISOString(),
28
+ seq: ++this.seqCounter,
29
+ };
30
+ this.ring.push(obs);
31
+ if (this.ring.length > this.cap)
32
+ this.ring.shift();
33
+ if (this.file) {
34
+ // Fire-and-forget durable mirror; a failing disk never affects the call.
35
+ void this.appender(this.file, JSON.stringify(obs) + "\n").catch(() => { });
36
+ }
37
+ return obs;
38
+ }
39
+ /** Newest-first query with optional filters. */
40
+ list(opts = {}) {
41
+ const limit = opts.limit && opts.limit > 0 ? Math.min(opts.limit, this.cap) : 200;
42
+ const out = [];
43
+ for (let i = this.ring.length - 1; i >= 0 && out.length < limit; i--) {
44
+ const e = this.ring[i];
45
+ if (opts.from && e.ts < opts.from)
46
+ continue;
47
+ if (opts.to && e.ts > opts.to)
48
+ continue;
49
+ if (opts.principal && e.principal !== opts.principal)
50
+ continue;
51
+ if (opts.tool && e.tool !== opts.tool)
52
+ continue;
53
+ if (opts.outcome && e.outcome !== opts.outcome)
54
+ continue;
55
+ if (opts.decision && e.decision !== opts.decision)
56
+ continue;
57
+ out.push(e);
58
+ }
59
+ return out;
60
+ }
61
+ /** All observations at or after `sinceMs` (epoch millis), oldest-first. */
62
+ since(sinceMs) {
63
+ const cutoff = new Date(sinceMs).toISOString();
64
+ return this.ring.filter((e) => e.ts >= cutoff);
65
+ }
66
+ /** Every observation currently retained (oldest-first). */
67
+ all() {
68
+ return [...this.ring];
69
+ }
70
+ get size() {
71
+ return this.ring.length;
72
+ }
73
+ get persisted() {
74
+ return !!this.file;
75
+ }
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { InspectStore } from "./store.js";
4
+ function mk(over = {}) {
5
+ return {
6
+ principal: "key:bot",
7
+ auth: "apikey",
8
+ tenant: "default",
9
+ tool: "query_logs",
10
+ argShape: {},
11
+ outcome: "ok",
12
+ decision: "allow",
13
+ redactions: 0,
14
+ ...over,
15
+ };
16
+ }
17
+ describe("InspectStore", () => {
18
+ it("assigns ts + monotonic seq and returns the stored observation", () => {
19
+ let t = 1_700_000_000_000;
20
+ const s = new InspectStore({ now: () => t });
21
+ const a = s.record(mk());
22
+ t += 1000;
23
+ const b = s.record(mk());
24
+ assert.equal(a.seq, 1);
25
+ assert.equal(b.seq, 2);
26
+ assert.ok(a.ts < b.ts);
27
+ assert.equal(s.size, 2);
28
+ });
29
+ it("rings out the oldest beyond cap", () => {
30
+ const s = new InspectStore({ cap: 3 });
31
+ for (let i = 0; i < 5; i++)
32
+ s.record(mk({ tool: `t${i}` }));
33
+ assert.equal(s.size, 3);
34
+ const tools = s.all().map((o) => o.tool);
35
+ assert.deepEqual(tools, ["t2", "t3", "t4"]);
36
+ });
37
+ it("filters by principal / tool / outcome / decision, newest-first", () => {
38
+ const s = new InspectStore();
39
+ s.record(mk({ principal: "a", tool: "query_logs", outcome: "ok" }));
40
+ s.record(mk({ principal: "b", tool: "query_metrics", outcome: "error" }));
41
+ s.record(mk({ principal: "a", tool: "query_metrics", decision: "would-block" }));
42
+ assert.equal(s.list({ principal: "a" }).length, 2);
43
+ assert.equal(s.list({ tool: "query_metrics" }).length, 2);
44
+ assert.equal(s.list({ outcome: "error" }).length, 1);
45
+ assert.equal(s.list({ decision: "would-block" }).length, 1);
46
+ // newest first
47
+ assert.equal(s.list({ principal: "a" })[0].decision, "would-block");
48
+ });
49
+ it("respects limit", () => {
50
+ const s = new InspectStore();
51
+ for (let i = 0; i < 10; i++)
52
+ s.record(mk());
53
+ assert.equal(s.list({ limit: 4 }).length, 4);
54
+ });
55
+ it("since() returns observations at/after a cutoff", () => {
56
+ let t = 1_700_000_000_000;
57
+ const s = new InspectStore({ now: () => t });
58
+ s.record(mk({ tool: "old" }));
59
+ t += 60_000;
60
+ const cut = t;
61
+ t += 1000;
62
+ s.record(mk({ tool: "new" }));
63
+ const recent = s.since(cut);
64
+ assert.equal(recent.length, 1);
65
+ assert.equal(recent[0].tool, "new");
66
+ });
67
+ it("mirrors to the file appender when configured; never throws on append failure", () => {
68
+ const lines = [];
69
+ const s = new InspectStore({ file: "inspect-store-test.jsonl", appender: async (_f, l) => { lines.push(l); } });
70
+ assert.equal(s.persisted, true);
71
+ s.record(mk({ tool: "logged" }));
72
+ // appender is fire-and-forget; the line is queued synchronously here
73
+ assert.equal(lines.length, 1);
74
+ assert.match(lines[0], /"tool":"logged"/);
75
+ const boom = new InspectStore({ file: "inspect-store-test.jsonl", appender: async () => { throw new Error("disk full"); } });
76
+ assert.doesNotThrow(() => boom.record(mk()));
77
+ });
78
+ });
@@ -6,6 +6,9 @@ export declare const connectorCalls: Counter<"type" | "source" | "outcome" | "op
6
6
  export declare const apiRequests: Counter<"status" | "route" | "method">;
7
7
  export declare const mcpActiveSessions: Gauge<string>;
8
8
  export declare const auditDlqDepth: Gauge<string>;
9
+ export declare const inspectEvents: Counter<"tool" | "outcome" | "decision">;
10
+ /** Record one inspection observation in the self-metrics registry. Never throws. */
11
+ export declare function recordInspectEvent(tool: string, outcome: string, decision: string): void;
9
12
  /**
10
13
  * Wrap a (potentially async) tool handler to record call count + latency.
11
14
  * Outcome is "ok" or "error" — never throws on its own.
@@ -48,6 +48,25 @@ export const auditDlqDepth = new Gauge({
48
48
  help: "Number of audit entries waiting in the webhook-sink dead-letter queue.",
49
49
  registers: [selfRegistry],
50
50
  });
51
+ // Inspect: one counter per recorded tool-call observation, labelled by tool,
52
+ // outcome (ok/error) and decision (allow/would-block/blocked). Lets operators
53
+ // graph deviation/block rates straight from Prometheus alongside the in-app
54
+ // Flows view.
55
+ export const inspectEvents = new Counter({
56
+ name: "obsmcp_inspect_events_total",
57
+ help: "Inspect observations by tool, outcome and policy decision.",
58
+ labelNames: ["tool", "outcome", "decision"],
59
+ registers: [selfRegistry],
60
+ });
61
+ /** Record one inspection observation in the self-metrics registry. Never throws. */
62
+ export function recordInspectEvent(tool, outcome, decision) {
63
+ try {
64
+ inspectEvents.inc({ tool, outcome, decision });
65
+ }
66
+ catch {
67
+ /* metrics must never affect the call path */
68
+ }
69
+ }
51
70
  /**
52
71
  * Wrap a (potentially async) tool handler to record call count + latency.
53
72
  * Outcome is "ok" or "error" — never throws on its own.
@@ -27,6 +27,7 @@ export const EGRESS_ALLOWLIST = [
27
27
  { prefix: "index.ts", reason: "connector-hub plugin install of an operator/registry-requested tarball URL" },
28
28
  { prefix: "auth/oidc/", reason: "OIDC client calls the operator-configured OMCP_OIDC_ISSUER for discovery, JWKS, and code-exchange" },
29
29
  { prefix: "auth/policy/", reason: "OpaPolicyEngine queries the operator-configured OMCP_OPA_URL on every RBAC decision" },
30
+ { prefix: "enrich/rdap.ts", reason: "enrich_ips RDAP fallback — OFF by default; only when the operator opts in with OMCP_IP_ENRICH_RDAP does it query the RDAP bootstrap. No call otherwise; the air-gapped default is preserved" },
30
31
  ];
31
32
  /**
32
33
  * Hard-blocked analytics/telemetry SDKs — matches an *import/require of the
@@ -43,3 +43,10 @@ export declare function tenantFromClaim(claims: Record<string, unknown>, claimPa
43
43
  * cognitive load stays low. Invalid tenant strings normalise to
44
44
  * DEFAULT_TENANT silently. */
45
45
  export declare function parseKeyTenants(raw: string | undefined): Map<string, string>;
46
+ /** Is the deployment ACTIVELY multi-tenant — i.e. configured to place any
47
+ * identity in a NON-default tenant? True when an OIDC tenant claim is set,
48
+ * or OMCP_KEY_TENANTS maps a credential to a tenant other than `default`.
49
+ * The single-tenant default (no claim, no non-default key mapping) returns
50
+ * false, so the open-source path stays free. This is the predicate the boot
51
+ * sequence uses to decide whether the `tenancy` entitlement is required. */
52
+ export declare function isMultiTenantConfigured(oidcTenantClaim: string | undefined, keyTenantsEnv: string | undefined): boolean;
@@ -95,3 +95,18 @@ export function parseKeyTenants(raw) {
95
95
  }
96
96
  return out;
97
97
  }
98
+ /** Is the deployment ACTIVELY multi-tenant — i.e. configured to place any
99
+ * identity in a NON-default tenant? True when an OIDC tenant claim is set,
100
+ * or OMCP_KEY_TENANTS maps a credential to a tenant other than `default`.
101
+ * The single-tenant default (no claim, no non-default key mapping) returns
102
+ * false, so the open-source path stays free. This is the predicate the boot
103
+ * sequence uses to decide whether the `tenancy` entitlement is required. */
104
+ export function isMultiTenantConfigured(oidcTenantClaim, keyTenantsEnv) {
105
+ if (oidcTenantClaim && oidcTenantClaim.trim().length > 0)
106
+ return true;
107
+ for (const t of parseKeyTenants(keyTenantsEnv).values()) {
108
+ if (t && t !== DEFAULT_TENANT)
109
+ return true;
110
+ }
111
+ return false;
112
+ }
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { DEFAULT_TENANT, MAX_TENANT_LENGTH, normaliseTenant, tenantFromClaim, parseKeyTenants, } from "./context.js";
3
+ import { DEFAULT_TENANT, MAX_TENANT_LENGTH, normaliseTenant, tenantFromClaim, parseKeyTenants, isMultiTenantConfigured, } from "./context.js";
4
4
  test("normaliseTenant — happy paths", () => {
5
5
  assert.equal(normaliseTenant("acme"), "acme");
6
6
  assert.equal(normaliseTenant("ACME"), "acme", "lowercases");
@@ -70,3 +70,20 @@ test("parseKeyTenants — undefined / empty returns empty map", () => {
70
70
  assert.equal(parseKeyTenants(undefined).size, 0);
71
71
  assert.equal(parseKeyTenants("").size, 0);
72
72
  });
73
+ test("isMultiTenantConfigured — single-tenant default is false (OSS path stays free)", () => {
74
+ // No OIDC tenant claim, no key-tenant mapping → single-tenant.
75
+ assert.equal(isMultiTenantConfigured(undefined, undefined), false);
76
+ assert.equal(isMultiTenantConfigured("", ""), false);
77
+ // Mappings that only ever resolve to `default` are NOT multi-tenant.
78
+ assert.equal(isMultiTenantConfigured("", "agent=default;ci=default"), false);
79
+ // Invalid tenant strings normalise to `default`, so they don't trip it.
80
+ assert.equal(isMultiTenantConfigured("", "agent=!!!bad!!!"), false);
81
+ });
82
+ test("isMultiTenantConfigured — true once a non-default tenant is configured", () => {
83
+ // An OIDC tenant claim alone activates multi-tenancy.
84
+ assert.equal(isMultiTenantConfigured("tid", undefined), true);
85
+ assert.equal(isMultiTenantConfigured(" tenant ", undefined), true);
86
+ // A non-default key→tenant mapping activates it.
87
+ assert.equal(isMultiTenantConfigured("", "agent=acme"), true);
88
+ assert.equal(isMultiTenantConfigured("", "agent=default;ci=bigco"), true);
89
+ });
@@ -1,4 +1,5 @@
1
1
  import { IpEnrichmentDataset } from "../enrich/ip-dataset.js";
2
+ import type { RdapResolver } from "../enrich/rdap.js";
2
3
  import { type RequestContext } from "../context.js";
3
4
  export declare const enrichIpsDefinition: {
4
5
  name: "enrich_ips";
@@ -15,8 +16,11 @@ export interface IpEnrichmentResult {
15
16
  asn?: string;
16
17
  org?: string;
17
18
  hosting?: boolean;
19
+ /** Which backend produced the hit — "dataset" (offline CSV) or "rdap"
20
+ * (online fallback). Absent when not found. */
21
+ via?: "dataset" | "rdap";
18
22
  }
19
- export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext): {
23
+ export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, args: EnrichIpsArgs, _ctx?: RequestContext, rdap?: RdapResolver | null): Promise<{
20
24
  content: {
21
25
  type: "text";
22
26
  text: string;
@@ -27,4 +31,4 @@ export declare function enrichIpsHandler(dataset: IpEnrichmentDataset | null, ar
27
31
  type: "text";
28
32
  text: string;
29
33
  }[];
30
- };
34
+ }>;
@@ -14,15 +14,18 @@ const MAX_IPS = 1000;
14
14
  function isValidIp(ip) {
15
15
  return ipv4ToInt(ip) !== null || ipv6ToBigInt(ip) !== null;
16
16
  }
17
- export function enrichIpsHandler(dataset, args,
17
+ export async function enrichIpsHandler(dataset, args,
18
18
  // The RequestContext seam — enrich_ips doesn't scope by tenant today (the
19
19
  // dataset is a single process-wide table), but every tool handler threads
20
20
  // ctx so access-control / audit can attach without a signature change later.
21
- _ctx = defaultContext()) {
22
- if (!dataset) {
21
+ _ctx = defaultContext(),
22
+ // Optional online RDAP fallback (issue #477). Present only when the operator
23
+ // set OMCP_IP_ENRICH_RDAP=on; absent → no external call (air-gapped default).
24
+ rdap) {
25
+ if (!dataset && !rdap) {
23
26
  return errorResponse("IP enrichment is not configured. Set OMCP_IP_ENRICH_FILE to a local CSV " +
24
- "(network,country,city,asn,org,hosting) to enable offline geo/ASN/hosting " +
25
- "lookups there is no external API call, so it stays air-gapped.");
27
+ "(network,country,city,asn,org,hosting) for offline lookups (air-gapped), " +
28
+ "or OMCP_IP_ENRICH_RDAP=on for an online RDAP fallback (country/org only).");
26
29
  }
27
30
  const ips = args.ips;
28
31
  if (!Array.isArray(ips) || ips.length === 0) {
@@ -34,20 +37,31 @@ _ctx = defaultContext()) {
34
37
  const results = [];
35
38
  let invalid = 0;
36
39
  let matched = 0;
40
+ let viaRdap = 0;
37
41
  for (const ip of ips) {
38
42
  if (typeof ip !== "string" || !isValidIp(ip)) {
39
43
  invalid++;
40
44
  results.push({ ip: String(ip), found: false });
41
45
  continue;
42
46
  }
43
- const hit = dataset.lookup(ip);
47
+ // Offline CSV is preferred (city precision, air-gapped). RDAP only fills
48
+ // gaps the dataset didn't cover, and only when the operator opted in.
49
+ const hit = dataset ? dataset.lookup(ip) : null;
44
50
  if (hit) {
45
51
  matched++;
46
- results.push({ ip, found: true, ...hit });
52
+ results.push({ ip, found: true, via: "dataset", ...hit });
53
+ continue;
47
54
  }
48
- else {
49
- results.push({ ip, found: false });
55
+ if (rdap) {
56
+ const r = await rdap.lookup(ip);
57
+ if (r) {
58
+ matched++;
59
+ viaRdap++;
60
+ results.push({ ip, found: true, via: "rdap", ...r });
61
+ continue;
62
+ }
50
63
  }
64
+ results.push({ ip, found: false });
51
65
  }
52
66
  return {
53
67
  content: [
@@ -55,8 +69,15 @@ _ctx = defaultContext()) {
55
69
  type: "text",
56
70
  text: JSON.stringify({
57
71
  results,
58
- summary: { total: ips.length, matched, unmatched: ips.length - matched - invalid, invalid },
59
- datasetSize: dataset.size,
72
+ summary: {
73
+ total: ips.length,
74
+ matched,
75
+ unmatched: ips.length - matched - invalid,
76
+ invalid,
77
+ ...(rdap ? { viaRdap } : {}),
78
+ },
79
+ datasetSize: dataset?.size ?? 0,
80
+ ...(rdap ? { rdapEnabled: true } : {}),
60
81
  }, null, 2),
61
82
  },
62
83
  ],