@thotischner/observability-mcp 1.5.1 → 1.7.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.
@@ -0,0 +1,510 @@
1
+ // Enterprise gate — the OPTIONAL seam that lets a deployment activate the
2
+ // source-available enterprise/ modules (RBAC, Catalog, Audit) behind a
3
+ // signed entitlement token.
4
+ //
5
+ // Hard rules this file obeys, so the Apache-2.0 core stays clean:
6
+ //
7
+ // 1. NO static import of anything under enterprise/. The modules are
8
+ // loaded with a dynamic import() of a computed specifier, so the
9
+ // Apache build never references FSL code.
10
+ // 2. DEFAULT-OFF and fail-safe. With no entitlement token configured —
11
+ // the only state the published npm/Docker artifact can be in, since
12
+ // enterprise/ is excluded from both — `enforceEntitledAccess` is an
13
+ // awaited no-op and behaviour is byte-for-byte unchanged.
14
+ // 3. enterprise/ is a sibling of mcp-server/. It is absent from the
15
+ // published artifact; a failed dynamic import must therefore leave
16
+ // the gate cleanly OFF, never crash.
17
+ //
18
+ // Activation (only when an operator opts in, from a full checkout that
19
+ // still contains enterprise/):
20
+ //
21
+ // OMCP_ENTITLEMENT_TOKEN signed token "<b64url payload>.<b64url sig>"
22
+ // OMCP_ENTITLEMENT_PUBKEY Ed25519 public key — PEM literal, or @<path>
23
+ // OMCP_RBAC_POLICY optional path to an RBAC policy JSON
24
+ // OMCP_CATALOG optional path to a product-catalog JSON
25
+ //
26
+ // Feature gating: the token's `features` must include "access-control"
27
+ // for RBAC/Catalog enforcement and "audit" for the audit log. If a
28
+ // policy/catalog file is configured but the token does not entitle
29
+ // "access-control", access is denied (fail-closed — a configured control
30
+ // must never be silently disabled).
31
+ import { readFileSync, appendFileSync, writeFileSync, renameSync } from "node:fs";
32
+ import { fileURLToPath } from "node:url";
33
+ import { dirname, join, resolve } from "node:path";
34
+ // enterprise/ relative to this file (src/ at dev, dist/ at runtime) — in
35
+ // both layouts ../../enterprise resolves to the repo-level directory.
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const ENTERPRISE_DIR = resolve(HERE, "..", "..", "enterprise");
38
+ /** Did the operator opt into any enterprise control? */
39
+ function controlsConfigured() {
40
+ return !!(process.env.OMCP_RBAC_POLICY || process.env.OMCP_CATALOG);
41
+ }
42
+ /** Map an inability-to-activate into off (no opt-in) or fail-closed. */
43
+ function inactive(reason) {
44
+ return controlsConfigured() ? { mode: "fail-closed", reason } : { mode: "off" };
45
+ }
46
+ let gatePromise = null;
47
+ // Audit log: a process singleton, created once and reused across every
48
+ // gate rebuild so the hash chain is continuous for the life of the
49
+ // process (a gate reset must never start a new chain segment mid-file).
50
+ let auditLogPromise = null;
51
+ async function getAuditLog() {
52
+ if (!auditLogPromise) {
53
+ auditLogPromise = (async () => {
54
+ try {
55
+ const auditMod = await import(join(ENTERPRISE_DIR, "audit", "index.mjs"));
56
+ const auditFile = process.env.OMCP_AUDIT_FILE;
57
+ const sink = auditFile
58
+ ? (entry) => appendFileSync(resolve(auditFile), JSON.stringify(entry) + "\n")
59
+ : undefined;
60
+ return auditMod.createAuditLog({ sink });
61
+ }
62
+ catch {
63
+ return null; // audit is best-effort; absence must not break enforcement
64
+ }
65
+ })();
66
+ }
67
+ return auditLogPromise;
68
+ }
69
+ /** Tests only: also drops the audit singleton for full isolation. */
70
+ export function _resetEnterpriseAudit() {
71
+ auditLogPromise = null;
72
+ }
73
+ function readPubKey(spec) {
74
+ if (spec.startsWith("@"))
75
+ return readFileSync(spec.slice(1), "utf8");
76
+ return spec.replace(/\\n/g, "\n");
77
+ }
78
+ function readJsonFile(path) {
79
+ return JSON.parse(readFileSync(resolve(path), "utf8"));
80
+ }
81
+ async function buildGate() {
82
+ const token = process.env.OMCP_ENTITLEMENT_TOKEN;
83
+ const pub = process.env.OMCP_ENTITLEMENT_PUBKEY;
84
+ if (!token || !pub) {
85
+ return inactive("no entitlement token configured");
86
+ }
87
+ // Dynamic, dependency-free import. If enterprise/ is absent (the
88
+ // published artifact) this throws → no opt-in means OFF; a configured
89
+ // control with absent modules means FAIL-CLOSED.
90
+ let entitlementMod;
91
+ try {
92
+ entitlementMod = await import(join(ENTERPRISE_DIR, "entitlement", "index.mjs"));
93
+ }
94
+ catch {
95
+ return inactive("enterprise/ modules not present");
96
+ }
97
+ let claims;
98
+ try {
99
+ const res = entitlementMod.verifyEntitlement(token, readPubKey(pub));
100
+ if (!res.valid)
101
+ return inactive(`entitlement invalid: ${res.reason}`);
102
+ claims = res.claims;
103
+ }
104
+ catch (e) {
105
+ return inactive(`entitlement verification error: ${String(e)}`);
106
+ }
107
+ const has = (f) => entitlementMod.hasFeature(claims, f);
108
+ const state = {
109
+ mode: "active",
110
+ claims,
111
+ accessControl: has("access-control"),
112
+ };
113
+ // Audit (best-effort; only if entitled and the module loads). The log
114
+ // is a PROCESS singleton, deliberately decoupled from the gate memo:
115
+ // resetting the gate (e.g. after an admin policy edit) must NOT sever
116
+ // the hash chain — an audited policy change that breaks tamper-evidence
117
+ // would defeat the point of auditing it.
118
+ if (has("audit")) {
119
+ const log = await getAuditLog();
120
+ if (log)
121
+ state.audit = log;
122
+ }
123
+ // RBAC / Catalog enforcers + their operator-supplied config.
124
+ if (process.env.OMCP_RBAC_POLICY) {
125
+ const rbacMod = await import(join(ENTERPRISE_DIR, "rbac", "index.mjs"));
126
+ state.enforceRbac = rbacMod.enforce;
127
+ state.rbacPolicy = readJsonFile(process.env.OMCP_RBAC_POLICY);
128
+ }
129
+ if (process.env.OMCP_CATALOG) {
130
+ const catMod = await import(join(ENTERPRISE_DIR, "catalog", "index.mjs"));
131
+ state.enforceCatalog = catMod.enforceCatalog;
132
+ state.catalog = readJsonFile(process.env.OMCP_CATALOG);
133
+ }
134
+ return state;
135
+ }
136
+ /** Reset memoised state (tests only). */
137
+ export function _resetEnterpriseGate() {
138
+ gatePromise = null;
139
+ }
140
+ /** Gate mode — for diagnostics (/api/info). */
141
+ export async function enterpriseGateStatus() {
142
+ if (!gatePromise)
143
+ gatePromise = buildGate();
144
+ const g = await gatePromise;
145
+ if (g.mode === "active")
146
+ return { active: true, mode: "active" };
147
+ if (g.mode === "fail-closed")
148
+ return { active: false, mode: "fail-closed", reason: g.reason };
149
+ return { active: false, mode: "off" };
150
+ }
151
+ /**
152
+ * The single enforcement point, called before every MCP tool runs.
153
+ *
154
+ * off: no opt-in, no entitlement → memoised no-op, returns
155
+ * immediately. Zero behaviour change for the OSS core;
156
+ * the only path the published artifact ever takes.
157
+ * fail-closed: a control was configured but the gate could not be
158
+ * activated → deny EVERY tool call (a broken/expired
159
+ * entitlement must never silently disable enforcement).
160
+ * active: record the decision (if audit entitled) and deny by
161
+ * throwing — the MCP SDK turns the throw into a clean tool
162
+ * error and the handler never runs.
163
+ */
164
+ export async function enforceEntitledAccess(ctx, request) {
165
+ if (!gatePromise)
166
+ gatePromise = buildGate();
167
+ const g = await gatePromise;
168
+ if (g.mode === "off")
169
+ return; // ← the only path the published artifact takes
170
+ if (g.mode === "fail-closed") {
171
+ throw new Error(`access denied: enterprise control configured but inactive (${g.reason})`);
172
+ }
173
+ const decide = () => {
174
+ // A configured control with no "access-control" entitlement is a
175
+ // misconfiguration we fail CLOSED on, never silently open.
176
+ const controlConfigured = !!(g.enforceRbac || g.enforceCatalog);
177
+ if (controlConfigured && !g.accessControl) {
178
+ return { allow: false, reason: "access-control not entitled by token" };
179
+ }
180
+ try {
181
+ if (g.enforceRbac)
182
+ g.enforceRbac(g.rbacPolicy, ctx, request);
183
+ if (g.enforceCatalog)
184
+ g.enforceCatalog(g.catalog, ctx, request);
185
+ return { allow: true, reason: "entitled" };
186
+ }
187
+ catch (e) {
188
+ return { allow: false, reason: e?.reason || e?.message || "denied" };
189
+ }
190
+ };
191
+ const decision = decide();
192
+ if (g.audit) {
193
+ try {
194
+ await g.audit.record({
195
+ kind: "access-decision",
196
+ principalId: ctx.principalId,
197
+ auth: ctx.auth,
198
+ correlationId: ctx.correlationId,
199
+ request,
200
+ allow: decision.allow,
201
+ reason: decision.reason,
202
+ });
203
+ }
204
+ catch {
205
+ /* audit failure must not change the access outcome */
206
+ }
207
+ }
208
+ if (!decision.allow) {
209
+ throw new Error(`access denied: ${decision.reason}`);
210
+ }
211
+ }
212
+ // ----------------------------------------------------------------------
213
+ // Read-only introspection for the management console. None of these ever
214
+ // expose the entitlement TOKEN or any private key — only the gate mode,
215
+ // the non-secret signed claims, and the operator-supplied policy/catalog/
216
+ // audit which are configuration, not credentials.
217
+ // ----------------------------------------------------------------------
218
+ /** Claim keys safe to surface (never the raw token / signature). */
219
+ const SAFE_CLAIM_KEYS = ["sub", "tier", "features", "iat", "exp"];
220
+ export async function enterpriseGateInfo() {
221
+ if (!gatePromise)
222
+ gatePromise = buildGate();
223
+ const g = await gatePromise;
224
+ const base = {
225
+ mode: g.mode,
226
+ active: g.mode === "active",
227
+ reason: g.mode === "fail-closed" ? g.reason : undefined,
228
+ rbacConfigured: !!process.env.OMCP_RBAC_POLICY,
229
+ catalogConfigured: !!process.env.OMCP_CATALOG,
230
+ auditConfigured: !!process.env.OMCP_AUDIT_FILE,
231
+ };
232
+ if (g.mode !== "active")
233
+ return { ...base, entitlement: null };
234
+ const c = (g.claims || {});
235
+ const entitlement = {};
236
+ for (const k of SAFE_CLAIM_KEYS)
237
+ if (k in c)
238
+ entitlement[k] = c[k];
239
+ return { ...base, entitlement };
240
+ }
241
+ function readConfigJson(envVar) {
242
+ const p = process.env[envVar];
243
+ if (!p)
244
+ return { configured: false };
245
+ try {
246
+ return { configured: true, data: JSON.parse(readFileSync(resolve(p), "utf8")) };
247
+ }
248
+ catch (e) {
249
+ return { configured: true, error: String(e) };
250
+ }
251
+ }
252
+ /** The loaded RBAC policy (read-only view). */
253
+ export function enterprisePolicyView() {
254
+ return readConfigJson("OMCP_RBAC_POLICY");
255
+ }
256
+ /** The loaded product catalog (read-only view). */
257
+ export function enterpriseCatalogView() {
258
+ return readConfigJson("OMCP_CATALOG");
259
+ }
260
+ /** Recent audit decisions + a tamper-evidence check over the whole log. */
261
+ export async function enterpriseAuditTail(limit = 50) {
262
+ const p = process.env.OMCP_AUDIT_FILE;
263
+ if (!p)
264
+ return { configured: false };
265
+ let raw;
266
+ try {
267
+ raw = readFileSync(resolve(p), "utf8");
268
+ }
269
+ catch (e) {
270
+ return { configured: true, error: String(e) };
271
+ }
272
+ const all = raw
273
+ .split("\n")
274
+ .filter(Boolean)
275
+ .map((l) => {
276
+ try {
277
+ return JSON.parse(l);
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ })
283
+ .filter(Boolean);
284
+ let chain = { ok: null };
285
+ try {
286
+ const auditMod = await import(join(ENTERPRISE_DIR, "audit", "index.mjs"));
287
+ chain = auditMod.verifyChain(all); // over the FULL log, not just the tail
288
+ }
289
+ catch {
290
+ /* audit module absent → integrity unknown */
291
+ }
292
+ const n = Math.max(1, Math.min(limit || 50, 500));
293
+ return { configured: true, total: all.length, chain, entries: all.slice(-n) };
294
+ }
295
+ // ----------------------------------------------------------------------
296
+ // Phase 2: admin-gated RBAC policy write.
297
+ //
298
+ // Editing the RBAC policy IS editing the security configuration, so the
299
+ // write path is NOT on the open local plane: it requires an API-key
300
+ // principal that the CURRENT policy grants the reserved admin capability
301
+ // `enterprise:admin`. First admin is bootstrapped via the policy file.
302
+ // Every change is recorded to the audit log, and a policy that would
303
+ // strip the writer's own admin capability is rejected (anti-lockout).
304
+ // ----------------------------------------------------------------------
305
+ export const ADMIN_CAP = "enterprise:admin";
306
+ /** Structural validation — never trust a PUT body. */
307
+ export function validatePolicyShape(p) {
308
+ if (!p || typeof p !== "object" || Array.isArray(p))
309
+ return "policy must be a JSON object";
310
+ if (typeof p.roles !== "object" || p.roles === null || Array.isArray(p.roles))
311
+ return "policy.roles must be an object";
312
+ if (typeof p.bindings !== "object" || p.bindings === null || Array.isArray(p.bindings))
313
+ return "policy.bindings must be an object";
314
+ if (p.defaultRoles !== undefined && !Array.isArray(p.defaultRoles))
315
+ return "policy.defaultRoles must be an array";
316
+ for (const [name, role] of Object.entries(p.roles)) {
317
+ if (!role || typeof role !== "object")
318
+ return `role '${name}' must be an object`;
319
+ for (const k of ["tools", "sources", "services"]) {
320
+ if (role[k] !== undefined && !Array.isArray(role[k]))
321
+ return `role '${name}.${k}' must be an array`;
322
+ }
323
+ }
324
+ for (const [pr, roles] of Object.entries(p.bindings)) {
325
+ if (!Array.isArray(roles))
326
+ return `binding '${pr}' must be an array of role names`;
327
+ }
328
+ return null;
329
+ }
330
+ async function rbacEnforcer() {
331
+ try {
332
+ const m = await import(join(ENTERPRISE_DIR, "rbac", "index.mjs"));
333
+ return m.enforce;
334
+ }
335
+ catch {
336
+ return null;
337
+ }
338
+ }
339
+ /** Does `policy` grant `principalId` the reserved admin capability? */
340
+ async function policyGrantsAdmin(policy, principalId) {
341
+ const enforce = await rbacEnforcer();
342
+ if (!enforce)
343
+ return false;
344
+ try {
345
+ enforce(policy, { principalId, auth: "apikey" }, { tool: ADMIN_CAP });
346
+ return true;
347
+ }
348
+ catch {
349
+ return false;
350
+ }
351
+ }
352
+ /**
353
+ * Authorize an admin action for `principalId` against the CURRENT
354
+ * on-disk policy (read fresh, never the memoised copy).
355
+ */
356
+ export async function authorizeAdmin(principalId) {
357
+ if (!gatePromise)
358
+ gatePromise = buildGate();
359
+ const g = await gatePromise;
360
+ if (g.mode !== "active")
361
+ return { ok: false, status: 409, error: `gate not active (mode: ${g.mode})` };
362
+ if (!process.env.OMCP_RBAC_POLICY)
363
+ return { ok: false, status: 409, error: "no RBAC policy configured" };
364
+ if (!principalId)
365
+ return { ok: false, status: 401, error: "authentication required" };
366
+ let current;
367
+ try {
368
+ current = JSON.parse(readFileSync(resolve(process.env.OMCP_RBAC_POLICY), "utf8"));
369
+ }
370
+ catch (e) {
371
+ return { ok: false, status: 500, error: `current policy unreadable: ${String(e)}` };
372
+ }
373
+ if (!(await policyGrantsAdmin(current, principalId))) {
374
+ return { ok: false, status: 403, error: `principal '${principalId}' lacks the '${ADMIN_CAP}' capability` };
375
+ }
376
+ return { ok: true, status: 200 };
377
+ }
378
+ /**
379
+ * Replace the RBAC policy. Caller must have passed authorizeAdmin first.
380
+ * Validates, blocks self-lockout, writes atomically, audits, and
381
+ * invalidates the gate memo so enforcement picks up the new policy.
382
+ */
383
+ export async function updateRbacPolicy(principalId, next) {
384
+ const shapeErr = validatePolicyShape(next);
385
+ if (shapeErr)
386
+ return { ok: false, status: 400, error: shapeErr };
387
+ if (!(await policyGrantsAdmin(next, principalId))) {
388
+ return {
389
+ ok: false,
390
+ status: 400,
391
+ error: `refused: the new policy would remove '${principalId}' own '${ADMIN_CAP}' capability (anti-lockout)`,
392
+ };
393
+ }
394
+ const path = resolve(process.env.OMCP_RBAC_POLICY);
395
+ let before = "";
396
+ try {
397
+ before = readFileSync(path, "utf8");
398
+ }
399
+ catch {
400
+ /* first write — no prior */
401
+ }
402
+ const serialized = JSON.stringify(next, null, 2) + "\n";
403
+ try {
404
+ const tmp = path + ".tmp-" + process.pid;
405
+ writeFileSync(tmp, serialized);
406
+ renameSync(tmp, path); // atomic replace
407
+ }
408
+ catch (e) {
409
+ return { ok: false, status: 500, error: `write failed: ${String(e)}` };
410
+ }
411
+ // Audit the change (best-effort; never blocks the write outcome).
412
+ try {
413
+ if (!gatePromise)
414
+ gatePromise = buildGate();
415
+ const g = await gatePromise;
416
+ if (g.mode === "active" && g.audit) {
417
+ await g.audit.record({
418
+ kind: "policy-change",
419
+ target: "rbac",
420
+ principalId,
421
+ bytesBefore: before.length,
422
+ bytesAfter: serialized.length,
423
+ });
424
+ }
425
+ }
426
+ catch {
427
+ /* audit failure must not fail the write */
428
+ }
429
+ _resetEnterpriseGate(); // next enforcement rebuilds with the new policy
430
+ return { ok: true, status: 200 };
431
+ }
432
+ // ----------------------------------------------------------------------
433
+ // Phase 3: admin-gated CATALOG write. Same admin model as the RBAC write
434
+ // (authorizeAdmin is RBAC-based and independent of the catalog, so a
435
+ // catalog edit carries no self-lockout risk). Validate, atomic write,
436
+ // audit, invalidate the gate memo.
437
+ // ----------------------------------------------------------------------
438
+ /** Structural validation for a product catalog PUT body. */
439
+ export function validateCatalogShape(c) {
440
+ if (!c || typeof c !== "object" || Array.isArray(c))
441
+ return "catalog must be a JSON object";
442
+ if (typeof c.products !== "object" || c.products === null || Array.isArray(c.products))
443
+ return "catalog.products must be an object";
444
+ if (typeof c.grants !== "object" || c.grants === null || Array.isArray(c.grants))
445
+ return "catalog.grants must be an object";
446
+ if (c.defaultProducts !== undefined && !Array.isArray(c.defaultProducts))
447
+ return "catalog.defaultProducts must be an array";
448
+ for (const [name, prod] of Object.entries(c.products)) {
449
+ if (!prod || typeof prod !== "object")
450
+ return `product '${name}' must be an object`;
451
+ if (!Array.isArray(prod.sources))
452
+ return `product '${name}.sources' must be an array`;
453
+ for (const k of ["services", "tools"]) {
454
+ if (prod[k] !== undefined && !Array.isArray(prod[k]))
455
+ return `product '${name}.${k}' must be an array`;
456
+ }
457
+ }
458
+ for (const [pr, prods] of Object.entries(c.grants)) {
459
+ if (!Array.isArray(prods))
460
+ return `grant '${pr}' must be an array of product names`;
461
+ }
462
+ return null;
463
+ }
464
+ /**
465
+ * Replace the product catalog. Caller must have passed authorizeAdmin.
466
+ * Validates, writes atomically, audits, invalidates the gate memo.
467
+ */
468
+ export async function updateCatalog(principalId, next) {
469
+ if (!process.env.OMCP_CATALOG)
470
+ return { ok: false, status: 409, error: "no catalog configured" };
471
+ const shapeErr = validateCatalogShape(next);
472
+ if (shapeErr)
473
+ return { ok: false, status: 400, error: shapeErr };
474
+ const path = resolve(process.env.OMCP_CATALOG);
475
+ let before = "";
476
+ try {
477
+ before = readFileSync(path, "utf8");
478
+ }
479
+ catch {
480
+ /* first write */
481
+ }
482
+ const serialized = JSON.stringify(next, null, 2) + "\n";
483
+ try {
484
+ const tmp = path + ".tmp-" + process.pid;
485
+ writeFileSync(tmp, serialized);
486
+ renameSync(tmp, path);
487
+ }
488
+ catch (e) {
489
+ return { ok: false, status: 500, error: `write failed: ${String(e)}` };
490
+ }
491
+ try {
492
+ if (!gatePromise)
493
+ gatePromise = buildGate();
494
+ const g = await gatePromise;
495
+ if (g.mode === "active" && g.audit) {
496
+ await g.audit.record({
497
+ kind: "policy-change",
498
+ target: "catalog",
499
+ principalId,
500
+ bytesBefore: before.length,
501
+ bytesAfter: serialized.length,
502
+ });
503
+ }
504
+ }
505
+ catch {
506
+ /* audit failure must not fail the write */
507
+ }
508
+ _resetEnterpriseGate();
509
+ return { ok: true, status: 200 };
510
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,178 @@
1
+ import { describe, it, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { defaultContext } from "./context.js";
7
+ import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, validatePolicyShape, validateCatalogShape, authorizeAdmin, _resetEnterpriseGate, } from "./enterprise-gate.js";
8
+ // These tests run in the mcp-server sandbox where enterprise/ is ABSENT
9
+ // (it is excluded from the npm package and the Docker build context) —
10
+ // exactly the published-artifact state. They pin the security contract:
11
+ //
12
+ // - no opt-in → OFF, perfect no-op (zero behaviour change)
13
+ // - control configured → FAIL-CLOSED when the gate can't activate
14
+ // (a broken/absent entitlement must DENY,
15
+ // never silently open)
16
+ function clearEnv() {
17
+ for (const k of [
18
+ "OMCP_ENTITLEMENT_TOKEN",
19
+ "OMCP_ENTITLEMENT_PUBKEY",
20
+ "OMCP_RBAC_POLICY",
21
+ "OMCP_CATALOG",
22
+ "OMCP_AUDIT_FILE",
23
+ ]) {
24
+ delete process.env[k];
25
+ }
26
+ _resetEnterpriseGate();
27
+ }
28
+ describe("enterprise-gate — OFF (no opt-in, published-artifact state)", () => {
29
+ afterEach(clearEnv);
30
+ it("no entitlement, no controls → awaited no-op, gate mode 'off'", async () => {
31
+ clearEnv();
32
+ await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool: "query_metrics", service: "payment" }));
33
+ const st = await enterpriseGateStatus();
34
+ assert.equal(st.active, false);
35
+ assert.equal(st.mode, "off");
36
+ });
37
+ it("token set but NO control configured → still OFF (no opt-in), no throw", async () => {
38
+ clearEnv();
39
+ process.env.OMCP_ENTITLEMENT_TOKEN = "deadbeef.cafebabe";
40
+ process.env.OMCP_ENTITLEMENT_PUBKEY = "-----BEGIN PUBLIC KEY-----\\nX\\n-----END PUBLIC KEY-----";
41
+ _resetEnterpriseGate();
42
+ await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool: "list_sources" }));
43
+ assert.equal((await enterpriseGateStatus()).mode, "off");
44
+ });
45
+ it("every tool name passes through cleanly when OFF", async () => {
46
+ clearEnv();
47
+ for (const tool of [
48
+ "list_sources",
49
+ "list_services",
50
+ "query_metrics",
51
+ "query_logs",
52
+ "get_service_health",
53
+ "detect_anomalies",
54
+ ]) {
55
+ await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool }));
56
+ }
57
+ });
58
+ it("gate state is memoised across calls", async () => {
59
+ clearEnv();
60
+ assert.deepEqual(await enterpriseGateStatus(), await enterpriseGateStatus());
61
+ });
62
+ });
63
+ describe("enterprise-gate — FAIL-CLOSED (opted in, cannot activate)", () => {
64
+ afterEach(clearEnv);
65
+ it("RBAC policy configured but enterprise/ absent → DENY every tool call", async () => {
66
+ clearEnv();
67
+ const dir = mkdtempSync(join(tmpdir(), "gate-fc-"));
68
+ const policy = join(dir, "rbac.json");
69
+ writeFileSync(policy, JSON.stringify({ roles: {}, bindings: {} }));
70
+ process.env.OMCP_RBAC_POLICY = policy; // operator opted into a control
71
+ _resetEnterpriseGate();
72
+ const st = await enterpriseGateStatus();
73
+ assert.equal(st.active, false);
74
+ assert.equal(st.mode, "fail-closed");
75
+ await assert.rejects(() => enforceEntitledAccess(defaultContext(), { tool: "query_metrics" }), /access denied: enterprise control configured but inactive/);
76
+ });
77
+ it("control configured + no token → fail-closed (not a silent open)", async () => {
78
+ clearEnv();
79
+ process.env.OMCP_CATALOG = "/nonexistent/catalog.json";
80
+ _resetEnterpriseGate();
81
+ assert.equal((await enterpriseGateStatus()).mode, "fail-closed");
82
+ await assert.rejects(() => enforceEntitledAccess(defaultContext(), { tool: "list_services" }), /access denied/);
83
+ });
84
+ });
85
+ describe("enterprise-gate — read-only console introspection", () => {
86
+ afterEach(clearEnv);
87
+ it("gateInfo: off → entitlement null, no token ever exposed", async () => {
88
+ clearEnv();
89
+ process.env.OMCP_ENTITLEMENT_TOKEN = "SECRET.SHOULD-NEVER-LEAK";
90
+ process.env.OMCP_ENTITLEMENT_PUBKEY = "x";
91
+ _resetEnterpriseGate();
92
+ const info = await enterpriseGateInfo();
93
+ assert.equal(info.active, false);
94
+ assert.equal(info.entitlement, null);
95
+ assert.equal("rbacConfigured" in info, true);
96
+ const dump = JSON.stringify(info);
97
+ assert.equal(dump.includes("SECRET"), false, "token must never appear in gate info");
98
+ });
99
+ it("gateInfo: configured-flags reflect env", async () => {
100
+ clearEnv();
101
+ process.env.OMCP_RBAC_POLICY = "/tmp/x.json";
102
+ process.env.OMCP_AUDIT_FILE = "/tmp/a.jsonl";
103
+ _resetEnterpriseGate();
104
+ const info = await enterpriseGateInfo();
105
+ assert.equal(info.rbacConfigured, true);
106
+ assert.equal(info.catalogConfigured, false);
107
+ assert.equal(info.auditConfigured, true);
108
+ });
109
+ it("policy/catalog view: not configured vs file error", () => {
110
+ clearEnv();
111
+ assert.deepEqual(enterprisePolicyView(), { configured: false });
112
+ assert.deepEqual(enterpriseCatalogView(), { configured: false });
113
+ const dir = mkdtempSync(join(tmpdir(), "gate-ro-"));
114
+ const f = join(dir, "p.json");
115
+ writeFileSync(f, '{"roles":{"a":{"tools":["*"]}},"bindings":{}}');
116
+ process.env.OMCP_RBAC_POLICY = f;
117
+ const v = enterprisePolicyView();
118
+ assert.equal(v.configured, true);
119
+ assert.deepEqual(Object.keys(v.data.roles), ["a"]);
120
+ process.env.OMCP_RBAC_POLICY = "/no/such/file.json";
121
+ const e = enterprisePolicyView();
122
+ assert.equal(e.configured, true);
123
+ assert.ok(e.error);
124
+ });
125
+ it("audit tail: not configured when no audit file", async () => {
126
+ clearEnv();
127
+ assert.deepEqual(await enterpriseAuditTail(10), { configured: false });
128
+ });
129
+ });
130
+ describe("enterprise-gate — P2 admin RBAC write", () => {
131
+ afterEach(clearEnv);
132
+ it("validatePolicyShape accepts a well-formed policy", () => {
133
+ assert.equal(validatePolicyShape({ roles: { a: { tools: ["*"] } }, bindings: { p: ["a"] }, defaultRoles: [] }), null);
134
+ });
135
+ it("validatePolicyShape rejects malformed shapes", () => {
136
+ assert.match(validatePolicyShape(null) || "", /must be a JSON object/);
137
+ assert.match(validatePolicyShape([]) || "", /must be a JSON object/);
138
+ assert.match(validatePolicyShape({ bindings: {} }) || "", /roles must be an object/);
139
+ assert.match(validatePolicyShape({ roles: {} }) || "", /bindings must be an object/);
140
+ assert.match(validatePolicyShape({ roles: {}, bindings: {}, defaultRoles: "x" }) || "", /defaultRoles must be an array/);
141
+ assert.match(validatePolicyShape({ roles: { r: { tools: "x" } }, bindings: {} }) || "", /role 'r.tools' must be an array/);
142
+ assert.match(validatePolicyShape({ roles: {}, bindings: { p: "x" } }) || "", /binding 'p' must be an array/);
143
+ });
144
+ it("authorizeAdmin denies when the gate is not active", async () => {
145
+ clearEnv(); // no entitlement → mode off
146
+ const r = await authorizeAdmin("someone");
147
+ assert.equal(r.ok, false);
148
+ assert.equal(r.status, 409);
149
+ assert.match(r.error ?? "", /gate not active/);
150
+ });
151
+ it("authorizeAdmin requires a principal once a control is configured", async () => {
152
+ clearEnv();
153
+ process.env.OMCP_RBAC_POLICY = "/tmp/none.json"; // fail-closed (no token)
154
+ _resetEnterpriseGate();
155
+ const r = await authorizeAdmin(null);
156
+ assert.equal(r.ok, false);
157
+ // gate is fail-closed here → still 409 (not active); never silently allows
158
+ assert.equal(r.ok, false);
159
+ });
160
+ });
161
+ describe("enterprise-gate — P3 catalog write validation", () => {
162
+ it("validateCatalogShape accepts a well-formed catalog", () => {
163
+ assert.equal(validateCatalogShape({
164
+ products: { p: { sources: ["*"], services: ["a"] } },
165
+ grants: { who: ["p"] },
166
+ defaultProducts: [],
167
+ }), null);
168
+ });
169
+ it("validateCatalogShape rejects malformed shapes", () => {
170
+ assert.match(validateCatalogShape(null) || "", /must be a JSON object/);
171
+ assert.match(validateCatalogShape({ grants: {} }) || "", /products must be an object/);
172
+ assert.match(validateCatalogShape({ products: {} }) || "", /grants must be an object/);
173
+ assert.match(validateCatalogShape({ products: { p: {} }, grants: {} }) || "", /product 'p.sources' must be an array/);
174
+ assert.match(validateCatalogShape({ products: { p: { sources: [], services: "x" } }, grants: {} }) || "", /product 'p.services' must be an array/);
175
+ assert.match(validateCatalogShape({ products: {}, grants: { g: "x" } }) || "", /grant 'g' must be an array/);
176
+ assert.match(validateCatalogShape({ products: {}, grants: {}, defaultProducts: 1 }) || "", /defaultProducts must be an array/);
177
+ });
178
+ });