@thotischner/observability-mcp 1.8.1 → 3.0.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 (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -27,6 +27,7 @@ import { BuiltinPolicyEngine } from "./engine.js";
27
27
  export const VALID_RESOURCES = new Set([
28
28
  "sources", "services", "health", "topology", "settings",
29
29
  "connectors", "audit", "catalog", "users", "redaction",
30
+ "products",
30
31
  ]);
31
32
  export const VALID_ACTIONS = new Set(["read", "write", "delete", "bypass"]);
32
33
  export class PolicyLoadError extends Error {
@@ -98,3 +99,39 @@ export function loadPolicyFromFile(path) {
98
99
  }
99
100
  return loadPolicyFromString(text, `file:${path}`);
100
101
  }
102
+ /** Render a policy map into the YAML/JSON shape the loader reads.
103
+ * Pure helper — separated from the file-write step so a future
104
+ * PolicyEngine implementation that doesn't speak the file format
105
+ * can compose differently. */
106
+ export function serializePolicy(policy) {
107
+ // Lock in the field order so a round-trip-through-this-function
108
+ // is stable diffs in a version-controlled file. Roles sorted
109
+ // alphabetically; grants sorted by (resource, action) inside
110
+ // each role.
111
+ const rolesOut = {};
112
+ for (const role of Object.keys(policy).sort()) {
113
+ const grants = policy[role] || [];
114
+ const sorted = grants
115
+ .slice()
116
+ .sort((a, b) => (a.resource + ":" + a.action).localeCompare(b.resource + ":" + b.action))
117
+ .map((g) => ({ resource: g.resource, action: g.action }));
118
+ rolesOut[role] = sorted;
119
+ }
120
+ return yaml.dump({ roles: rolesOut }, { sortKeys: false, lineWidth: 100 });
121
+ }
122
+ /** Atomic write of the policy file. Same tmp+rename pattern used by
123
+ * products + users — a crash mid-write leaves the previous file
124
+ * intact. mode 0o600 so the on-disk RBAC catalogue isn't
125
+ * world-readable on multi-tenant hosts. */
126
+ export async function writePolicyFile(path, policy) {
127
+ // Validate via the parse path before writing — a bad input
128
+ // shape would otherwise produce a file the boot loader then
129
+ // rejects (fail-closed reboot). Validate-then-write keeps the
130
+ // good-policy invariant.
131
+ loadPolicyFromString(serializePolicy(policy), "(in-memory)");
132
+ const { writeFile, rename } = await import("node:fs/promises");
133
+ const text = serializePolicy(policy);
134
+ const tmp = path + ".tmp";
135
+ await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
136
+ await rename(tmp, path);
137
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writePolicyFile, loadPolicyFromFile, loadPolicyFromString, serializePolicy, VALID_RESOURCES } from "./loader.js";
4
+ import { BuiltinPolicyEngine } from "./engine.js";
5
+ test("VALID_RESOURCES — includes products (closes a pre-existing inconsistency vs rbac.ts)", () => {
6
+ assert.ok(VALID_RESOURCES.has("products"), "products must be a recognised resource for file-loaded policies");
7
+ });
8
+ test("serializePolicy — round-trips through the parser cleanly", () => {
9
+ const text = serializePolicy({
10
+ admin: [
11
+ { resource: "sources", action: "delete" },
12
+ { resource: "users", action: "delete" },
13
+ ],
14
+ viewer: [{ resource: "sources", action: "read" }],
15
+ });
16
+ // Parsing the serialised text via loadPolicyFromString must yield
17
+ // an engine with the same role grants — round-trip is stable.
18
+ const e = loadPolicyFromString(text, "test");
19
+ assert.deepEqual(e.roles().sort(), ["admin", "viewer"]);
20
+ const admin = e.list(["admin"]).map((p) => p.resource + ":" + p.action).sort();
21
+ assert.deepEqual(admin, ["sources:delete", "users:delete"]);
22
+ });
23
+ test("serializePolicy — deterministic ordering (roles + grants both sorted)", () => {
24
+ const a = serializePolicy({
25
+ z: [{ resource: "sources", action: "write" }, { resource: "audit", action: "read" }],
26
+ a: [{ resource: "users", action: "read" }],
27
+ });
28
+ const b = serializePolicy({
29
+ a: [{ resource: "users", action: "read" }],
30
+ z: [{ resource: "audit", action: "read" }, { resource: "sources", action: "write" }],
31
+ });
32
+ // Same logical policy → byte-identical text. Important for git-diff sanity.
33
+ assert.equal(a, b);
34
+ });
35
+ test("writePolicyFile — atomic round-trip preserves shape", async () => {
36
+ const { mkdtemp, rm } = await import("node:fs/promises");
37
+ const { tmpdir } = await import("node:os");
38
+ const { join } = await import("node:path");
39
+ const dir = await mkdtemp(join(tmpdir(), "omcp-policy-"));
40
+ try {
41
+ const path = join(dir, "policy.yaml");
42
+ await writePolicyFile(path, {
43
+ admin: [{ resource: "sources", action: "delete" }],
44
+ operator: [{ resource: "sources", action: "write" }],
45
+ });
46
+ const engine = loadPolicyFromFile(path);
47
+ assert.deepEqual(engine.roles().sort(), ["admin", "operator"]);
48
+ }
49
+ finally {
50
+ await rm(dir, { recursive: true, force: true });
51
+ }
52
+ });
53
+ test("writePolicyFile — rejects an invalid resource before writing the file", async () => {
54
+ const { mkdtemp, rm, readdir } = await import("node:fs/promises");
55
+ const { tmpdir } = await import("node:os");
56
+ const { join } = await import("node:path");
57
+ const dir = await mkdtemp(join(tmpdir(), "omcp-policy-reject-"));
58
+ try {
59
+ const path = join(dir, "policy.yaml");
60
+ await assert.rejects(writePolicyFile(path, {
61
+ admin: [{ resource: "nope", action: "read" }],
62
+ }), /unknown/i);
63
+ // No file (or tmp) was created — validate-then-write held.
64
+ const entries = await readdir(dir);
65
+ assert.deepEqual(entries, []);
66
+ }
67
+ finally {
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ test("BuiltinPolicyEngine.replace — mutates the inner map in place (gate closures see the new policy)", () => {
72
+ const engine = new BuiltinPolicyEngine({
73
+ admin: [{ resource: "sources", action: "delete" }],
74
+ });
75
+ // Capture a reference to the raw map BEFORE replace — verify the
76
+ // reference is preserved (hot-swap, not reassign).
77
+ const before = engine.raw();
78
+ engine.replace({
79
+ admin: [{ resource: "sources", action: "delete" }, { resource: "audit", action: "read" }],
80
+ viewer: [{ resource: "sources", action: "read" }],
81
+ });
82
+ assert.equal(before, engine.raw(), "raw() must return the same object reference after replace()");
83
+ assert.deepEqual(engine.roles().sort(), ["admin", "viewer"]);
84
+ assert.equal(engine.evaluate(["admin"], "audit", "read").allowed, true);
85
+ assert.equal(engine.evaluate(["viewer"], "sources", "read").allowed, true);
86
+ });
@@ -27,7 +27,7 @@
27
27
  * for auth/oidc/, and OPA traffic stays inside the cluster).
28
28
  */
29
29
  import type { Permission, Resource, Action } from "../rbac.js";
30
- import type { PolicyEngine, EvalResult } from "./engine.js";
30
+ import type { PolicyEngine, EvalResult, EvalContext } from "./engine.js";
31
31
  export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
32
32
  export interface OpaConfig {
33
33
  /** Base URL, e.g. http://opa:8181 (no trailing slash). */
@@ -57,13 +57,13 @@ export declare class OpaPolicyEngine implements PolicyEngine {
57
57
  private cacheKey;
58
58
  private now;
59
59
  private query;
60
- evaluate(roles: string[] | undefined, resource: Resource, action: Action): EvalResult;
60
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action, ctx?: EvalContext): EvalResult;
61
61
  /** Async warm of the evaluate cache. Public so a long-running
62
62
  * caller can `await engine.warmEvaluate(...)` before the gate
63
63
  * check if it cannot tolerate the warming-deny window. */
64
- warmEvaluate(roles: string[], resource: string, action: string): Promise<EvalResult>;
65
- list(roles: string[] | undefined): Permission[];
66
- warmList(roles: string[]): Promise<Permission[]>;
64
+ warmEvaluate(roles: string[], resource: string, action: string, tenant?: string): Promise<EvalResult>;
65
+ list(roles: string[] | undefined, ctx?: EvalContext): Permission[];
66
+ warmList(roles: string[], tenant?: string): Promise<Permission[]>;
67
67
  roles(): string[];
68
68
  kind(): string;
69
69
  }
@@ -44,10 +44,13 @@ export class OpaPolicyEngine {
44
44
  this.cfg = { timeoutMs: 1500, ...cfg };
45
45
  this.fetcher = cfg.fetcher ?? ((u, i) => fetch(u, i));
46
46
  }
47
- cacheKey(roles, resource, action) {
47
+ cacheKey(roles, resource, action, tenant) {
48
48
  // NUL-delimited so role names containing "," / "|" can't alias
49
49
  // across role sets ({"a,b"} would otherwise collide with {"a","b"}).
50
- return roles.slice().sort().join("\x00") + "\x01" + resource + "\x01" + action;
50
+ // Tenant is part of the key so cross-tenant decisions don't share
51
+ // cache slots — required once we thread tenant into the OPA input.
52
+ const tk = tenant || "";
53
+ return roles.slice().sort().join("\x00") + "\x01" + resource + "\x01" + action + "\x01" + tk;
51
54
  }
52
55
  now() {
53
56
  return Date.now();
@@ -74,14 +77,15 @@ export class OpaPolicyEngine {
74
77
  clearTimeout(timer);
75
78
  }
76
79
  }
77
- evaluate(roles, resource, action) {
80
+ evaluate(roles, resource, action, ctx) {
78
81
  const rs = roles && roles.length > 0 ? roles : [];
82
+ const tenant = ctx?.tenant;
79
83
  // The PolicyEngine.evaluate contract is sync to keep the hot
80
84
  // gate path off the await stack, so we serve from the cache
81
85
  // synchronously and warm the cache lazily on miss. Misses fall
82
86
  // back to a conservative deny + the cache will be populated for
83
87
  // next time.
84
- const key = this.cacheKey(rs, resource, action);
88
+ const key = this.cacheKey(rs, resource, action, tenant);
85
89
  const cached = this.cache.get(key);
86
90
  if (cached && this.now() - cached.at < this.cacheTtlMs)
87
91
  return cached.result;
@@ -89,16 +93,22 @@ export class OpaPolicyEngine {
89
93
  // first miss (deny while warming) but the next call within the
90
94
  // TTL returns the real verdict. For sync-required contracts we
91
95
  // accept that trade-off vs. blocking every request handler.
92
- void this.warmEvaluate(rs, resource, action);
96
+ void this.warmEvaluate(rs, resource, action, tenant);
93
97
  return { allowed: false, reason: "OPA decision pending (warming cache); request again" };
94
98
  }
95
99
  /** Async warm of the evaluate cache. Public so a long-running
96
100
  * caller can `await engine.warmEvaluate(...)` before the gate
97
101
  * check if it cannot tolerate the warming-deny window. */
98
- async warmEvaluate(roles, resource, action) {
99
- const key = this.cacheKey(roles, resource, action);
102
+ async warmEvaluate(roles, resource, action, tenant) {
103
+ const key = this.cacheKey(roles, resource, action, tenant);
100
104
  try {
101
- const out = await this.query({ input: { roles, resource, action } });
105
+ // input.tenant is always included (undefined null in JSON
106
+ // serialisation, omitted by JSON.stringify default) so Rego
107
+ // authors can write `input.tenant == "acme"` rules without
108
+ // tripping on missing-field. When the caller didn't supply
109
+ // tenant we still include the key with `undefined` value;
110
+ // JSON.stringify drops it cleanly.
111
+ const out = await this.query({ input: { roles, resource, action, tenant } });
102
112
  const raw = out.result;
103
113
  let result;
104
114
  if (raw === true || raw === false) {
@@ -124,20 +134,21 @@ export class OpaPolicyEngine {
124
134
  return result;
125
135
  }
126
136
  }
127
- list(roles) {
137
+ list(roles, ctx) {
128
138
  if (!roles || roles.length === 0)
129
139
  return [];
130
- const key = roles.slice().sort().join("\x00");
140
+ const tenant = ctx?.tenant || "";
141
+ const key = roles.slice().sort().join("\x00") + "\x01" + tenant;
131
142
  const cached = this.listCache.get(key);
132
143
  if (cached && this.now() - cached.at < this.listCacheTtlMs)
133
144
  return cached.perms;
134
- void this.warmList(roles);
145
+ void this.warmList(roles, ctx?.tenant);
135
146
  return [];
136
147
  }
137
- async warmList(roles) {
138
- const key = roles.slice().sort().join("\x00");
148
+ async warmList(roles, tenant) {
149
+ const key = roles.slice().sort().join("\x00") + "\x01" + (tenant || "");
139
150
  try {
140
- const out = await this.query({ input: { roles, list: true } });
151
+ const out = await this.query({ input: { roles, list: true, tenant } });
141
152
  const raw = out.result;
142
153
  let perms = [];
143
154
  if (raw && typeof raw === "object" && Array.isArray(raw.permissions)) {
@@ -141,6 +141,54 @@ test("OpaPolicyEngine — cache key delimiter prevents role-name collision (\"a,
141
141
  assert.equal(r2.allowed, false);
142
142
  assert.equal(calls.length, 2, "the two role-sets must not collide on the cache key");
143
143
  });
144
+ test("OpaPolicyEngine — tenant flows into the OPA input shape on evaluate", async () => {
145
+ let seenBody = null;
146
+ const fetcher = (async (_url, init) => {
147
+ seenBody = init?.body ? JSON.parse(String(init.body)) : null;
148
+ return new Response(JSON.stringify({ result: true }), { status: 200 });
149
+ });
150
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
151
+ await e.warmEvaluate(["admin"], "sources", "write", "acme");
152
+ const body = seenBody;
153
+ assert.equal(body?.input?.tenant, "acme", "tenant must reach the Rego input as input.tenant");
154
+ assert.equal(body?.input?.resource, "sources");
155
+ assert.equal(body?.input?.action, "write");
156
+ });
157
+ test("OpaPolicyEngine — tenant flows into the OPA input shape on list", async () => {
158
+ let seenBody = null;
159
+ const fetcher = (async (_url, init) => {
160
+ seenBody = init?.body ? JSON.parse(String(init.body)) : null;
161
+ return new Response(JSON.stringify({ result: { permissions: [] } }), { status: 200 });
162
+ });
163
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
164
+ await e.warmList(["admin"], "acme");
165
+ const body = seenBody;
166
+ assert.equal(body?.input?.tenant, "acme");
167
+ assert.equal(body?.input?.list, true);
168
+ });
169
+ test("OpaPolicyEngine — cache key isolates tenants (same roles+resource+action, different tenants → two OPA calls)", async () => {
170
+ let calls = 0;
171
+ const fetcher = (async (_url, init) => {
172
+ calls++;
173
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
174
+ // Return a different verdict per tenant so a cache mix-up would
175
+ // surface as a wrong allowed value, not just an extra call.
176
+ const allowed = body?.input?.tenant === "acme";
177
+ return new Response(JSON.stringify({ result: allowed }), { status: 200 });
178
+ });
179
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
180
+ const acme = await e.warmEvaluate(["admin"], "sources", "read", "acme");
181
+ const bigco = await e.warmEvaluate(["admin"], "sources", "read", "bigco");
182
+ assert.equal(acme.allowed, true);
183
+ assert.equal(bigco.allowed, false);
184
+ assert.equal(calls, 2, "tenants must not share cache slots");
185
+ // Repeat — both come from cache, no extra OPA hits.
186
+ const acme2 = e.evaluate(["admin"], "sources", "read", { tenant: "acme" });
187
+ const bigco2 = e.evaluate(["admin"], "sources", "read", { tenant: "bigco" });
188
+ assert.equal(acme2.allowed, true);
189
+ assert.equal(bigco2.allowed, false);
190
+ assert.equal(calls, 2);
191
+ });
144
192
  test("OpaPolicyEngine — sort-stable cache key (role-set order doesn't matter)", async () => {
145
193
  let calls = 0;
146
194
  const fetcher = (async (_u) => {
@@ -16,13 +16,18 @@
16
16
  */
17
17
  import type { RequestHandler } from "express";
18
18
  import type { AuthRuntime } from "./middleware.js";
19
+ import type { PolicyEngine } from "./policy/engine.js";
19
20
  export type Action = "read" | "write" | "delete" | "bypass";
20
21
  export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products";
21
22
  export interface Permission {
22
23
  resource: Resource;
23
24
  action: Action;
24
25
  }
25
- /** Built-in default policy. Operators can replace this via OMCP_RBAC_POLICY in a follow-up. */
26
+ /** Built-in default policy. Operators replace this via OMCP_RBAC_POLICY_FILE
27
+ * (YAML/JSON file → BuiltinPolicyEngine) or OMCP_OPA_URL (external
28
+ * OPA → OpaPolicyEngine). The gate consumes whichever via
29
+ * `buildRequirePermissionFromEngine` so tenant-conditional Rego
30
+ * rules can fire. */
26
31
  export declare const DEFAULT_POLICY: Record<string, Permission[]>;
27
32
  /** Resolve whether the given role set grants the requested permission. */
28
33
  export declare function hasPermission(roles: string[] | undefined, resource: Resource, action: Action, policy?: Record<string, Permission[]>): boolean;
@@ -34,6 +39,23 @@ export declare function hasPermission(roles: string[] | undefined, resource: Res
34
39
  * with no session means anonymous mode is active.
35
40
  */
36
41
  export declare function buildRequirePermission(runtime: AuthRuntime, resource: Resource, action: Action, policy?: Record<string, Permission[]>): RequestHandler;
42
+ /**
43
+ * Engine-aware variant of `buildRequirePermission`. Prefer this when an
44
+ * external policy engine (OPA, custom Rego) is in play — the legacy
45
+ * `(roles, resource, action) → boolean` map cannot carry the active
46
+ * tenant, so a Rego rule like `allow { input.tenant == "acme" }` can
47
+ * never fire if you go through the map. This variant calls
48
+ * `engine.evaluate(roles, resource, action, { tenant: session.tenant })`
49
+ * so tenant-conditional rules see the input they need.
50
+ *
51
+ * Anonymous mode stays a no-op — same as the map variant.
52
+ *
53
+ * Performance: `evaluate` is sync by contract. OPA hits its cache; on
54
+ * first miss it returns a conservative deny and warms in the
55
+ * background — the second request inside the TTL gets the real
56
+ * verdict. Documented in opa.ts.
57
+ */
58
+ export declare function buildRequirePermissionFromEngine(runtime: AuthRuntime, resource: Resource, action: Action, engine: PolicyEngine): RequestHandler;
37
59
  /** Convenience snapshot for `/api/me` — list every permission the
38
60
  * given role set unlocks. Used by the UI to hide write controls the
39
61
  * current user can't trigger anyway. */
package/dist/auth/rbac.js CHANGED
@@ -14,7 +14,11 @@
14
14
  * Basic mode runs every mutating /api/* request through `requirePermission`
15
15
  * middleware (mounted in index.ts alongside `requireSession`).
16
16
  */
17
- /** Built-in default policy. Operators can replace this via OMCP_RBAC_POLICY in a follow-up. */
17
+ /** Built-in default policy. Operators replace this via OMCP_RBAC_POLICY_FILE
18
+ * (YAML/JSON file → BuiltinPolicyEngine) or OMCP_OPA_URL (external
19
+ * OPA → OpaPolicyEngine). The gate consumes whichever via
20
+ * `buildRequirePermissionFromEngine` so tenant-conditional Rego
21
+ * rules can fire. */
18
22
  export const DEFAULT_POLICY = {
19
23
  viewer: [
20
24
  { resource: "sources", action: "read" },
@@ -96,6 +100,44 @@ export function buildRequirePermission(runtime, resource, action, policy = DEFAU
96
100
  });
97
101
  };
98
102
  }
103
+ /**
104
+ * Engine-aware variant of `buildRequirePermission`. Prefer this when an
105
+ * external policy engine (OPA, custom Rego) is in play — the legacy
106
+ * `(roles, resource, action) → boolean` map cannot carry the active
107
+ * tenant, so a Rego rule like `allow { input.tenant == "acme" }` can
108
+ * never fire if you go through the map. This variant calls
109
+ * `engine.evaluate(roles, resource, action, { tenant: session.tenant })`
110
+ * so tenant-conditional rules see the input they need.
111
+ *
112
+ * Anonymous mode stays a no-op — same as the map variant.
113
+ *
114
+ * Performance: `evaluate` is sync by contract. OPA hits its cache; on
115
+ * first miss it returns a conservative deny and warms in the
116
+ * background — the second request inside the TTL gets the real
117
+ * verdict. Documented in opa.ts.
118
+ */
119
+ export function buildRequirePermissionFromEngine(runtime, resource, action, engine) {
120
+ if (runtime.mode === "anonymous") {
121
+ return function rbacNoop(_req, _res, next) {
122
+ next();
123
+ };
124
+ }
125
+ return function rbacGateEngine(req, res, next) {
126
+ const sess = req.session;
127
+ const verdict = engine.evaluate(sess?.roles, resource, action, { tenant: sess?.tenant });
128
+ if (verdict.allowed) {
129
+ next();
130
+ return;
131
+ }
132
+ res.status(403).json({
133
+ error: "permission denied",
134
+ code: "OMCP_PERMISSION_DENIED",
135
+ required: { resource, action },
136
+ have: sess?.roles ?? [],
137
+ reason: verdict.reason,
138
+ });
139
+ };
140
+ }
99
141
  /** Convenience snapshot for `/api/me` — list every permission the
100
142
  * given role set unlocks. Used by the UI to hide write controls the
101
143
  * current user can't trigger anyway. */
@@ -119,3 +119,65 @@ test("DEFAULT_POLICY shape — has the three built-in roles", () => {
119
119
  assert.ok(DEFAULT_POLICY.operator);
120
120
  assert.ok(DEFAULT_POLICY.admin);
121
121
  });
122
+ import { buildRequirePermissionFromEngine } from "./rbac.js";
123
+ import { BuiltinPolicyEngine } from "./policy/engine.js";
124
+ test("buildRequirePermissionFromEngine — anonymous always allows (no-op middleware)", () => {
125
+ const engine = new BuiltinPolicyEngine(DEFAULT_POLICY);
126
+ const mw = buildRequirePermissionFromEngine({ mode: "anonymous" }, "sources", "write", engine);
127
+ const res = mkRes();
128
+ let called = false;
129
+ mw(mkReq(), res, () => { called = true; });
130
+ assert.equal(called, true);
131
+ assert.equal(res.statusCode, 0);
132
+ });
133
+ test("buildRequirePermissionFromEngine — engine.evaluate verdict is surfaced verbatim in the 403 body", () => {
134
+ // Spy engine returns a custom reason so we can prove the gate forwards it.
135
+ const engine = {
136
+ evaluate: () => ({ allowed: false, reason: "test deny reason from engine" }),
137
+ list: () => [],
138
+ roles: () => [],
139
+ kind: () => "test",
140
+ };
141
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
142
+ const mw = buildRequirePermissionFromEngine(runtime, "sources", "write", engine);
143
+ const res = mkRes();
144
+ mw(mkReq(["viewer"]), res, () => undefined);
145
+ assert.equal(res.statusCode, 403);
146
+ const body = res.body;
147
+ assert.equal(body.code, "OMCP_PERMISSION_DENIED");
148
+ assert.equal(body.reason, "test deny reason from engine");
149
+ });
150
+ test("buildRequirePermissionFromEngine — passes session.tenant into engine.evaluate's EvalContext (load-bearing for OPA tenant rules)", () => {
151
+ // This is the property the OPA-input-tenant refine relies on:
152
+ // the gate MUST pass session.tenant to engine.evaluate so a Rego
153
+ // rule like `allow { input.tenant == "acme" }` can fire. Capture
154
+ // the ctx and assert it carries the tenant verbatim.
155
+ let seenCtx;
156
+ const engine = {
157
+ evaluate: (_roles, _r, _a, ctx) => {
158
+ seenCtx = ctx;
159
+ return { allowed: true, reason: "ok" };
160
+ },
161
+ list: () => [],
162
+ roles: () => [],
163
+ kind: () => "spy",
164
+ };
165
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
166
+ const mw = buildRequirePermissionFromEngine(runtime, "sources", "read", engine);
167
+ const res = mkRes();
168
+ const req = {
169
+ session: { sub: "u", name: "u", roles: ["viewer"], tenant: "acme", iat: 0, exp: Date.now() / 1000 + 60 },
170
+ };
171
+ mw(req, res, () => undefined);
172
+ assert.equal(seenCtx?.tenant, "acme", "tenant from session must flow into engine.evaluate ctx");
173
+ });
174
+ test("buildRequirePermissionFromEngine — sessionless basic-mode request denies via engine (roles undefined → no permission)", () => {
175
+ const engine = new BuiltinPolicyEngine(DEFAULT_POLICY);
176
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
177
+ const mw = buildRequirePermissionFromEngine(runtime, "sources", "read", engine);
178
+ const res = mkRes();
179
+ let called = false;
180
+ mw(mkReq(), res, () => { called = true; });
181
+ assert.equal(called, false);
182
+ assert.equal(res.statusCode, 403);
183
+ });
package/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseArgs, pickFreePort, composeOverride, resolveCatalogSource, formatPluginList, formatPluginInfo, resolveInstall, splitPassthrough, helmReleaseArgs, HELM_REPO_NAME, HELM_REPO_URL, HELP, } from "./lib.js";
9
9
  import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "../connectors/verify.js";
10
+ import { inspectorConfigCommand } from "./inspector-config.js";
10
11
  function pkgVersion() {
11
12
  try {
12
13
  const here = dirname(fileURLToPath(import.meta.url));
@@ -363,6 +364,8 @@ async function main() {
363
364
  return plugin(sub, positionals, flags);
364
365
  case "helm":
365
366
  return helm(sub, positionals[0] ?? "observability-mcp", passthrough);
367
+ case "inspector-config":
368
+ return inspectorConfigCommand();
366
369
  default:
367
370
  fail(`unknown command: ${command}\n\n${HELP}`);
368
371
  }
@@ -0,0 +1,9 @@
1
+ export interface InspectorConfig {
2
+ mcpServers: Record<string, {
3
+ url: string;
4
+ headers?: Record<string, string>;
5
+ }>;
6
+ }
7
+ export declare function buildInspectorConfig(env?: NodeJS.ProcessEnv): InspectorConfig;
8
+ /** CLI entrypoint. Prints JSON to stdout; exits 0 on success. */
9
+ export declare function inspectorConfigCommand(): void;
@@ -0,0 +1,28 @@
1
+ // `omcp inspector-config` — emit a config JSON the official MCP
2
+ // Inspector can consume. Reads OMCP_BASE_URL (default
3
+ // http://localhost:3000) and optionally OMCP_INSPECTOR_TOKEN to put
4
+ // in the Authorization header.
5
+ //
6
+ // Pipe straight into Inspector:
7
+ // npx @modelcontextprotocol/inspector --config <(omcp inspector-config)
8
+ //
9
+ // Or write to a file:
10
+ // omcp inspector-config > inspector.json
11
+ // npx @modelcontextprotocol/inspector --config inspector.json
12
+ export function buildInspectorConfig(env = process.env) {
13
+ const baseRaw = env.OMCP_BASE_URL?.trim() || "http://localhost:3000";
14
+ const base = baseRaw.replace(/\/$/, "");
15
+ const url = `${base}/mcp`;
16
+ const token = env.OMCP_INSPECTOR_TOKEN?.trim();
17
+ const name = env.OMCP_INSPECTOR_SERVER_NAME?.trim() || "observability-mcp";
18
+ const server = { url };
19
+ if (token) {
20
+ server.headers = { Authorization: `Bearer ${token}` };
21
+ }
22
+ return { mcpServers: { [name]: server } };
23
+ }
24
+ /** CLI entrypoint. Prints JSON to stdout; exits 0 on success. */
25
+ export function inspectorConfigCommand() {
26
+ const cfg = buildInspectorConfig();
27
+ process.stdout.write(JSON.stringify(cfg, null, 2) + "\n");
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildInspectorConfig } from "./inspector-config.js";
4
+ test("buildInspectorConfig: defaults to localhost:3000/mcp, no headers, observability-mcp server name", () => {
5
+ const cfg = buildInspectorConfig({});
6
+ assert.deepEqual(cfg, {
7
+ mcpServers: {
8
+ "observability-mcp": {
9
+ url: "http://localhost:3000/mcp",
10
+ },
11
+ },
12
+ });
13
+ });
14
+ test("buildInspectorConfig: trims trailing slash from base URL", () => {
15
+ const cfg = buildInspectorConfig({ OMCP_BASE_URL: "https://gw.example.com/" });
16
+ assert.equal(cfg.mcpServers["observability-mcp"].url, "https://gw.example.com/mcp");
17
+ });
18
+ test("buildInspectorConfig: token populates Authorization Bearer", () => {
19
+ const cfg = buildInspectorConfig({
20
+ OMCP_INSPECTOR_TOKEN: "tok-abc",
21
+ });
22
+ assert.equal(cfg.mcpServers["observability-mcp"].headers?.Authorization, "Bearer tok-abc");
23
+ });
24
+ test("buildInspectorConfig: custom server name", () => {
25
+ const cfg = buildInspectorConfig({
26
+ OMCP_INSPECTOR_SERVER_NAME: "my-gateway",
27
+ });
28
+ assert.ok("my-gateway" in cfg.mcpServers);
29
+ });
30
+ test("buildInspectorConfig: empty token trimmed → no headers key", () => {
31
+ const cfg = buildInspectorConfig({ OMCP_INSPECTOR_TOKEN: " " });
32
+ assert.equal(cfg.mcpServers["observability-mcp"].headers, undefined);
33
+ });
package/dist/cli/lib.d.ts CHANGED
@@ -92,4 +92,4 @@ export declare function splitPassthrough(argv: string[]): {
92
92
  };
93
93
  /** The helm argv for the install/upgrade step (repo add/update are fixed). */
94
94
  export declare function helmReleaseArgs(action: "install" | "upgrade", release: string, passthrough: string[]): string[];
95
- export declare const HELP = "omcp \u2014 observability-mcp control CLI\n\nUsage:\n omcp version Print CLI + server package version\n omcp doctor Check the local toolchain (docker, compose, helm, node)\n omcp demo up Start the full demo stack (auto-picks free host ports)\n omcp demo down Stop and remove the demo stack\n omcp demo status Show demo container status\n omcp plugin list List connectors from the hub catalog\n omcp plugin info <name> Show one connector's versions + verification info\n omcp plugin install <ref> Install name[@version]: download, verify, extract\n omcp plugin verify <dir> Verify an installed plugin dir against a trust root\n omcp helm install [release] helm repo add+update, then install the signed chart\n omcp helm upgrade [release] Same, as 'helm upgrade --install'\n omcp help Show this help\n\nPass extra helm flags after a literal --, e.g.:\n omcp helm upgrade obs -- -n monitoring --set sources.prometheusUrl=http://prom:9090\n\nFlags:\n --json Machine-readable output (doctor, status, plugin)\n --from <url|path> Catalog source (default: local checkout or the public hub)\n --offline-dir <dir> Airgapped: read <name>-<ver>.tgz[.sig] + manifest from <dir>\n --trust-root <pem> Verify signature+integrity against this PEM (fail-closed)\n --insecure Skip verification (NOT recommended; explicit opt-out)\n --dest <dir> Install target (default: $PLUGINS_DIR or ./plugins)\n --force Overwrite an existing install dir\n";
95
+ export declare const HELP = "omcp \u2014 observability-mcp control CLI\n\nUsage:\n omcp version Print CLI + server package version\n omcp doctor Check the local toolchain (docker, compose, helm, node)\n omcp demo up Start the full demo stack (auto-picks free host ports)\n omcp demo down Stop and remove the demo stack\n omcp demo status Show demo container status\n omcp plugin list List connectors from the hub catalog\n omcp plugin info <name> Show one connector's versions + verification info\n omcp plugin install <ref> Install name[@version]: download, verify, extract\n omcp plugin verify <dir> Verify an installed plugin dir against a trust root\n omcp helm install [release] helm repo add+update, then install the signed chart\n omcp helm upgrade [release] Same, as 'helm upgrade --install'\n omcp inspector-config Print an MCP Inspector config JSON pointing at the local gateway\n omcp help Show this help\n\nPass extra helm flags after a literal --, e.g.:\n omcp helm upgrade obs -- -n monitoring --set sources.prometheusUrl=http://prom:9090\n\nFlags:\n --json Machine-readable output (doctor, status, plugin)\n --from <url|path> Catalog source (default: local checkout or the public hub)\n --offline-dir <dir> Airgapped: read <name>-<ver>.tgz[.sig] + manifest from <dir>\n --trust-root <pem> Verify signature+integrity against this PEM (fail-closed)\n --insecure Skip verification (NOT recommended; explicit opt-out)\n --dest <dir> Install target (default: $PLUGINS_DIR or ./plugins)\n --force Overwrite an existing install dir\n";
package/dist/cli/lib.js CHANGED
@@ -169,6 +169,7 @@ Usage:
169
169
  omcp plugin verify <dir> Verify an installed plugin dir against a trust root
170
170
  omcp helm install [release] helm repo add+update, then install the signed chart
171
171
  omcp helm upgrade [release] Same, as 'helm upgrade --install'
172
+ omcp inspector-config Print an MCP Inspector config JSON pointing at the local gateway
172
173
  omcp help Show this help
173
174
 
174
175
  Pass extra helm flags after a literal --, e.g.:
@@ -0,0 +1 @@
1
+ export {};