@thotischner/observability-mcp 1.5.1 → 1.6.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.
- package/dist/enterprise-gate.d.ts +132 -0
- package/dist/enterprise-gate.js +510 -0
- package/dist/enterprise-gate.test.d.ts +1 -0
- package/dist/enterprise-gate.test.js +178 -0
- package/dist/index.js +78 -6
- package/dist/tools/get-service-health.js +11 -8
- package/dist/tools/handlers.test.js +31 -0
- package/dist/ui/index.html +1510 -67
- package/package.json +2 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { RequestContext } from "./context.js";
|
|
2
|
+
export interface ToolRequest {
|
|
3
|
+
tool: string;
|
|
4
|
+
source?: string;
|
|
5
|
+
service?: string;
|
|
6
|
+
}
|
|
7
|
+
type GateState = {
|
|
8
|
+
mode: "off";
|
|
9
|
+
} | {
|
|
10
|
+
mode: "fail-closed";
|
|
11
|
+
reason: string;
|
|
12
|
+
} | {
|
|
13
|
+
mode: "active";
|
|
14
|
+
claims: Record<string, unknown>;
|
|
15
|
+
accessControl: boolean;
|
|
16
|
+
enforceRbac?: (policy: unknown, ctx: unknown, req: unknown) => unknown;
|
|
17
|
+
enforceCatalog?: (catalog: unknown, ctx: unknown, req: unknown) => unknown;
|
|
18
|
+
rbacPolicy?: unknown;
|
|
19
|
+
catalog?: unknown;
|
|
20
|
+
audit?: {
|
|
21
|
+
record: (e: unknown) => Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** Tests only: also drops the audit singleton for full isolation. */
|
|
25
|
+
export declare function _resetEnterpriseAudit(): void;
|
|
26
|
+
/** Reset memoised state (tests only). */
|
|
27
|
+
export declare function _resetEnterpriseGate(): void;
|
|
28
|
+
/** Gate mode — for diagnostics (/api/info). */
|
|
29
|
+
export declare function enterpriseGateStatus(): Promise<{
|
|
30
|
+
active: boolean;
|
|
31
|
+
mode: GateState["mode"];
|
|
32
|
+
reason?: string;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* The single enforcement point, called before every MCP tool runs.
|
|
36
|
+
*
|
|
37
|
+
* off: no opt-in, no entitlement → memoised no-op, returns
|
|
38
|
+
* immediately. Zero behaviour change for the OSS core;
|
|
39
|
+
* the only path the published artifact ever takes.
|
|
40
|
+
* fail-closed: a control was configured but the gate could not be
|
|
41
|
+
* activated → deny EVERY tool call (a broken/expired
|
|
42
|
+
* entitlement must never silently disable enforcement).
|
|
43
|
+
* active: record the decision (if audit entitled) and deny by
|
|
44
|
+
* throwing — the MCP SDK turns the throw into a clean tool
|
|
45
|
+
* error and the handler never runs.
|
|
46
|
+
*/
|
|
47
|
+
export declare function enforceEntitledAccess(ctx: RequestContext, request: ToolRequest): Promise<void>;
|
|
48
|
+
export interface EnterpriseGateInfo {
|
|
49
|
+
mode: GateState["mode"];
|
|
50
|
+
active: boolean;
|
|
51
|
+
reason?: string;
|
|
52
|
+
rbacConfigured: boolean;
|
|
53
|
+
catalogConfigured: boolean;
|
|
54
|
+
auditConfigured: boolean;
|
|
55
|
+
entitlement: Record<string, unknown> | null;
|
|
56
|
+
}
|
|
57
|
+
export declare function enterpriseGateInfo(): Promise<EnterpriseGateInfo>;
|
|
58
|
+
/** The loaded RBAC policy (read-only view). */
|
|
59
|
+
export declare function enterprisePolicyView(): {
|
|
60
|
+
configured: false;
|
|
61
|
+
data?: undefined;
|
|
62
|
+
error?: undefined;
|
|
63
|
+
} | {
|
|
64
|
+
configured: true;
|
|
65
|
+
data: any;
|
|
66
|
+
error?: undefined;
|
|
67
|
+
} | {
|
|
68
|
+
configured: true;
|
|
69
|
+
error: string;
|
|
70
|
+
data?: undefined;
|
|
71
|
+
};
|
|
72
|
+
/** The loaded product catalog (read-only view). */
|
|
73
|
+
export declare function enterpriseCatalogView(): {
|
|
74
|
+
configured: false;
|
|
75
|
+
data?: undefined;
|
|
76
|
+
error?: undefined;
|
|
77
|
+
} | {
|
|
78
|
+
configured: true;
|
|
79
|
+
data: any;
|
|
80
|
+
error?: undefined;
|
|
81
|
+
} | {
|
|
82
|
+
configured: true;
|
|
83
|
+
error: string;
|
|
84
|
+
data?: undefined;
|
|
85
|
+
};
|
|
86
|
+
/** Recent audit decisions + a tamper-evidence check over the whole log. */
|
|
87
|
+
export declare function enterpriseAuditTail(limit?: number): Promise<{
|
|
88
|
+
configured: false;
|
|
89
|
+
error?: undefined;
|
|
90
|
+
total?: undefined;
|
|
91
|
+
chain?: undefined;
|
|
92
|
+
entries?: undefined;
|
|
93
|
+
} | {
|
|
94
|
+
configured: true;
|
|
95
|
+
error: string;
|
|
96
|
+
total?: undefined;
|
|
97
|
+
chain?: undefined;
|
|
98
|
+
entries?: undefined;
|
|
99
|
+
} | {
|
|
100
|
+
configured: true;
|
|
101
|
+
total: number;
|
|
102
|
+
chain: unknown;
|
|
103
|
+
entries: unknown[];
|
|
104
|
+
error?: undefined;
|
|
105
|
+
}>;
|
|
106
|
+
export declare const ADMIN_CAP = "enterprise:admin";
|
|
107
|
+
/** Structural validation — never trust a PUT body. */
|
|
108
|
+
export declare function validatePolicyShape(p: any): string | null;
|
|
109
|
+
export interface AdminResult {
|
|
110
|
+
ok: boolean;
|
|
111
|
+
status: number;
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Authorize an admin action for `principalId` against the CURRENT
|
|
116
|
+
* on-disk policy (read fresh, never the memoised copy).
|
|
117
|
+
*/
|
|
118
|
+
export declare function authorizeAdmin(principalId: string | null): Promise<AdminResult>;
|
|
119
|
+
/**
|
|
120
|
+
* Replace the RBAC policy. Caller must have passed authorizeAdmin first.
|
|
121
|
+
* Validates, blocks self-lockout, writes atomically, audits, and
|
|
122
|
+
* invalidates the gate memo so enforcement picks up the new policy.
|
|
123
|
+
*/
|
|
124
|
+
export declare function updateRbacPolicy(principalId: string, next: unknown): Promise<AdminResult>;
|
|
125
|
+
/** Structural validation for a product catalog PUT body. */
|
|
126
|
+
export declare function validateCatalogShape(c: any): string | null;
|
|
127
|
+
/**
|
|
128
|
+
* Replace the product catalog. Caller must have passed authorizeAdmin.
|
|
129
|
+
* Validates, writes atomically, audits, invalidates the gate memo.
|
|
130
|
+
*/
|
|
131
|
+
export declare function updateCatalog(principalId: string, next: unknown): Promise<AdminResult>;
|
|
132
|
+
export {};
|
|
@@ -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 {};
|