@thotischner/observability-mcp 1.7.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 (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -0,0 +1,69 @@
1
+ /**
2
+ * External OPA (Open Policy Agent) policy engine.
3
+ *
4
+ * Calls an OPA server over its Data API (POST /v1/data/<path>) to
5
+ * evaluate every RBAC decision against a Rego policy the operator
6
+ * authors and loads into OPA. This lets enterprise users keep their
7
+ * single source of policy truth (a Rego bundle deployed alongside
8
+ * everything else) instead of duplicating the rules in YAML here.
9
+ *
10
+ * Wire format (input):
11
+ * POST /v1/data/{package}
12
+ * { "input": { "roles": ["admin"], "resource": "sources", "action": "delete" } }
13
+ *
14
+ * Expected response shape:
15
+ * { "result": true | false }
16
+ * OR
17
+ * { "result": { "allowed": true|false, "reason"?: string, "permissions"?: [{resource, action}, ...] } }
18
+ *
19
+ * The second form lets an operator return both a verdict and the
20
+ * full per-role permission list from the same package, which the
21
+ * UI uses to render the policy snapshot. Plain boolean form is
22
+ * also accepted for minimal policies; in that case .list() returns
23
+ * an empty array with a "not supported by this OPA package" reason
24
+ * surfaced via kind().
25
+ *
26
+ * No new dependency: uses `fetch` (already in the egress allowlist
27
+ * for auth/oidc/, and OPA traffic stays inside the cluster).
28
+ */
29
+ import type { Permission, Resource, Action } from "../rbac.js";
30
+ import type { PolicyEngine, EvalResult, EvalContext } from "./engine.js";
31
+ export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
32
+ export interface OpaConfig {
33
+ /** Base URL, e.g. http://opa:8181 (no trailing slash). */
34
+ url: string;
35
+ /** Data path, e.g. "observability/authz". POSTed to /v1/data/<path>. */
36
+ packagePath: string;
37
+ /** Optional list of role names the operator wants the UI to show.
38
+ * OPA doesn't expose roles natively, so this is config-side. */
39
+ declaredRoles?: string[];
40
+ /** Optional bearer token for OPA's `--authentication=token`. */
41
+ bearerToken?: string;
42
+ /** Request timeout in ms. Default 1500. OPA decisions should be
43
+ * millisecond-fast; a slow OPA almost always means the network
44
+ * is wrong, not the policy. */
45
+ timeoutMs?: number;
46
+ /** Custom fetcher (tests). */
47
+ fetcher?: Fetcher;
48
+ }
49
+ export declare class OpaPolicyEngine implements PolicyEngine {
50
+ private readonly cfg;
51
+ private readonly fetcher;
52
+ private cache;
53
+ private readonly cacheTtlMs;
54
+ private listCache;
55
+ private readonly listCacheTtlMs;
56
+ constructor(cfg: OpaConfig);
57
+ private cacheKey;
58
+ private now;
59
+ private query;
60
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action, ctx?: EvalContext): EvalResult;
61
+ /** Async warm of the evaluate cache. Public so a long-running
62
+ * caller can `await engine.warmEvaluate(...)` before the gate
63
+ * check if it cannot tolerate the warming-deny window. */
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
+ roles(): string[];
68
+ kind(): string;
69
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * External OPA (Open Policy Agent) policy engine.
3
+ *
4
+ * Calls an OPA server over its Data API (POST /v1/data/<path>) to
5
+ * evaluate every RBAC decision against a Rego policy the operator
6
+ * authors and loads into OPA. This lets enterprise users keep their
7
+ * single source of policy truth (a Rego bundle deployed alongside
8
+ * everything else) instead of duplicating the rules in YAML here.
9
+ *
10
+ * Wire format (input):
11
+ * POST /v1/data/{package}
12
+ * { "input": { "roles": ["admin"], "resource": "sources", "action": "delete" } }
13
+ *
14
+ * Expected response shape:
15
+ * { "result": true | false }
16
+ * OR
17
+ * { "result": { "allowed": true|false, "reason"?: string, "permissions"?: [{resource, action}, ...] } }
18
+ *
19
+ * The second form lets an operator return both a verdict and the
20
+ * full per-role permission list from the same package, which the
21
+ * UI uses to render the policy snapshot. Plain boolean form is
22
+ * also accepted for minimal policies; in that case .list() returns
23
+ * an empty array with a "not supported by this OPA package" reason
24
+ * surfaced via kind().
25
+ *
26
+ * No new dependency: uses `fetch` (already in the egress allowlist
27
+ * for auth/oidc/, and OPA traffic stays inside the cluster).
28
+ */
29
+ export class OpaPolicyEngine {
30
+ cfg;
31
+ fetcher;
32
+ // Tiny per-(role, resource, action) cache so a render of /api/me
33
+ // doesn't fire 30 OPA calls per second. 5s TTL is short enough
34
+ // that a policy update at OPA is reflected promptly; long enough
35
+ // that bursts of UI loads coalesce.
36
+ cache = new Map();
37
+ cacheTtlMs = 5_000;
38
+ // List cache is per-role and slightly longer-lived since list() is
39
+ // only used for /api/me / Policy UI which the user doesn't refresh
40
+ // every 200ms.
41
+ listCache = new Map();
42
+ listCacheTtlMs = 30_000;
43
+ constructor(cfg) {
44
+ this.cfg = { timeoutMs: 1500, ...cfg };
45
+ this.fetcher = cfg.fetcher ?? ((u, i) => fetch(u, i));
46
+ }
47
+ cacheKey(roles, resource, action, tenant) {
48
+ // NUL-delimited so role names containing "," / "|" can't alias
49
+ // across role sets ({"a,b"} would otherwise collide with {"a","b"}).
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;
54
+ }
55
+ now() {
56
+ return Date.now();
57
+ }
58
+ async query(payload) {
59
+ const url = `${this.cfg.url.replace(/\/$/, "")}/v1/data/${this.cfg.packagePath.replace(/^\//, "")}`;
60
+ const ctrl = new AbortController();
61
+ const timer = setTimeout(() => ctrl.abort(), this.cfg.timeoutMs ?? 1500);
62
+ try {
63
+ const headers = { "content-type": "application/json" };
64
+ if (this.cfg.bearerToken)
65
+ headers.authorization = `Bearer ${this.cfg.bearerToken}`;
66
+ const res = await this.fetcher(url, {
67
+ method: "POST",
68
+ headers,
69
+ body: JSON.stringify(payload),
70
+ signal: ctrl.signal,
71
+ });
72
+ if (!res.ok)
73
+ throw new Error(`HTTP ${res.status} from ${url}`);
74
+ return (await res.json());
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+ evaluate(roles, resource, action, ctx) {
81
+ const rs = roles && roles.length > 0 ? roles : [];
82
+ const tenant = ctx?.tenant;
83
+ // The PolicyEngine.evaluate contract is sync to keep the hot
84
+ // gate path off the await stack, so we serve from the cache
85
+ // synchronously and warm the cache lazily on miss. Misses fall
86
+ // back to a conservative deny + the cache will be populated for
87
+ // next time.
88
+ const key = this.cacheKey(rs, resource, action, tenant);
89
+ const cached = this.cache.get(key);
90
+ if (cached && this.now() - cached.at < this.cacheTtlMs)
91
+ return cached.result;
92
+ // Fire and forget: populate the cache; this call is racy on
93
+ // first miss (deny while warming) but the next call within the
94
+ // TTL returns the real verdict. For sync-required contracts we
95
+ // accept that trade-off vs. blocking every request handler.
96
+ void this.warmEvaluate(rs, resource, action, tenant);
97
+ return { allowed: false, reason: "OPA decision pending (warming cache); request again" };
98
+ }
99
+ /** Async warm of the evaluate cache. Public so a long-running
100
+ * caller can `await engine.warmEvaluate(...)` before the gate
101
+ * check if it cannot tolerate the warming-deny window. */
102
+ async warmEvaluate(roles, resource, action, tenant) {
103
+ const key = this.cacheKey(roles, resource, action, tenant);
104
+ try {
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 } });
112
+ const raw = out.result;
113
+ let result;
114
+ if (raw === true || raw === false) {
115
+ result = { allowed: raw, reason: raw ? "allowed by OPA" : "denied by OPA" };
116
+ }
117
+ else if (raw && typeof raw === "object") {
118
+ result = {
119
+ allowed: !!raw.allowed,
120
+ reason: typeof raw.reason === "string" ? raw.reason : (raw.allowed ? "allowed by OPA" : "denied by OPA"),
121
+ };
122
+ }
123
+ else {
124
+ result = { allowed: false, reason: `OPA returned an unrecognised result shape: ${JSON.stringify(raw)}` };
125
+ }
126
+ this.cache.set(key, { at: this.now(), result });
127
+ return result;
128
+ }
129
+ catch (e) {
130
+ const result = { allowed: false, reason: `OPA query failed: ${e.message}` };
131
+ // Cache the failure for a SHORT window so a flapping OPA
132
+ // doesn't get hammered, but not for the full TTL.
133
+ this.cache.set(key, { at: this.now() - this.cacheTtlMs + 1000, result });
134
+ return result;
135
+ }
136
+ }
137
+ list(roles, ctx) {
138
+ if (!roles || roles.length === 0)
139
+ return [];
140
+ const tenant = ctx?.tenant || "";
141
+ const key = roles.slice().sort().join("\x00") + "\x01" + tenant;
142
+ const cached = this.listCache.get(key);
143
+ if (cached && this.now() - cached.at < this.listCacheTtlMs)
144
+ return cached.perms;
145
+ void this.warmList(roles, ctx?.tenant);
146
+ return [];
147
+ }
148
+ async warmList(roles, tenant) {
149
+ const key = roles.slice().sort().join("\x00") + "\x01" + (tenant || "");
150
+ try {
151
+ const out = await this.query({ input: { roles, list: true, tenant } });
152
+ const raw = out.result;
153
+ let perms = [];
154
+ if (raw && typeof raw === "object" && Array.isArray(raw.permissions)) {
155
+ perms = raw.permissions
156
+ .filter((p) => p && typeof p.resource === "string" && typeof p.action === "string")
157
+ .map((p) => ({ resource: p.resource, action: p.action }));
158
+ }
159
+ this.listCache.set(key, { at: this.now(), perms });
160
+ return perms;
161
+ }
162
+ catch {
163
+ this.listCache.set(key, { at: this.now(), perms: [] });
164
+ return [];
165
+ }
166
+ }
167
+ roles() {
168
+ return this.cfg.declaredRoles ?? [];
169
+ }
170
+ kind() {
171
+ return `opa:${this.cfg.url}`;
172
+ }
173
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,206 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { OpaPolicyEngine } from "./opa.js";
4
+ function mockFetcher(handler) {
5
+ return (async (url, init) => {
6
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
7
+ const result = handler(url, body);
8
+ return new Response(JSON.stringify({ result }), { status: 200, headers: { "content-type": "application/json" } });
9
+ });
10
+ }
11
+ test("OpaPolicyEngine — evaluate returns warming-deny on first call, real verdict after warm", async () => {
12
+ const calls = [];
13
+ const fetcher = (async (url, init) => {
14
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
15
+ calls.push({ url, body });
16
+ return new Response(JSON.stringify({ result: true }), { status: 200 });
17
+ });
18
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "observability/authz", fetcher });
19
+ // First call: cache miss → conservative deny + async fire.
20
+ const first = e.evaluate(["admin"], "sources", "write");
21
+ assert.equal(first.allowed, false);
22
+ assert.match(first.reason, /OPA decision pending/);
23
+ // After explicit warm, the cache holds the real verdict.
24
+ const real = await e.warmEvaluate(["admin"], "sources", "write");
25
+ assert.equal(real.allowed, true);
26
+ const cached = e.evaluate(["admin"], "sources", "write");
27
+ assert.equal(cached.allowed, true);
28
+ assert.equal(calls.length, 2, "expected exactly two POSTs: implicit lazy warm + explicit warm");
29
+ });
30
+ test("OpaPolicyEngine — accepts boolean and rich result shapes", async () => {
31
+ const e1 = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher: mockFetcher(() => true) });
32
+ assert.equal((await e1.warmEvaluate(["admin"], "sources", "read")).allowed, true);
33
+ const e2 = new OpaPolicyEngine({
34
+ url: "http://opa.test", packagePath: "p",
35
+ fetcher: mockFetcher(() => ({ allowed: false, reason: "blocked: not in office hours" })),
36
+ });
37
+ const r = await e2.warmEvaluate(["admin"], "sources", "read");
38
+ assert.equal(r.allowed, false);
39
+ assert.match(r.reason, /office hours/);
40
+ });
41
+ test("OpaPolicyEngine — unrecognised shape denies with a clear reason", async () => {
42
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher: mockFetcher(() => 42) });
43
+ const r = await e.warmEvaluate(["admin"], "sources", "read");
44
+ assert.equal(r.allowed, false);
45
+ assert.match(r.reason, /unrecognised result shape/);
46
+ });
47
+ test("OpaPolicyEngine — second evaluate() after the short failure cache reads the fresh OPA verdict", async () => {
48
+ // End-to-end recovery: failure → cache stale → next evaluate() hits
49
+ // the populated success result. We can't sleep in unit tests, so
50
+ // we drive the cache state directly: first warm hits OPA and
51
+ // caches the failure with an expired-ish timestamp (see opa.ts:
52
+ // failure cache stamped now - TTL + 1s = effectively expired in
53
+ // ~1s); second warm hits OPA again and caches a real success;
54
+ // third evaluate() returns the success synchronously from cache.
55
+ // This proves the engine never gets stuck denying after OPA recovers.
56
+ let calls = 0;
57
+ const fetcher = (async (_u) => {
58
+ calls++;
59
+ if (calls === 1)
60
+ return new Response("nope", { status: 503 });
61
+ return new Response(JSON.stringify({ result: true }), { status: 200 });
62
+ });
63
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
64
+ const denied = await e.warmEvaluate(["admin"], "sources", "read");
65
+ assert.equal(denied.allowed, false, "first call failure surfaces");
66
+ const recovered = await e.warmEvaluate(["admin"], "sources", "read");
67
+ assert.equal(recovered.allowed, true, "second warm re-queries and gets the recovered verdict");
68
+ // The success populated the cache; a subsequent evaluate() must
69
+ // return synchronously WITHOUT another fetch — this is the
70
+ // "engine doesn't loop forever in warming-deny after OPA recovers"
71
+ // invariant the user-facing gate cares about.
72
+ const cached = e.evaluate(["admin"], "sources", "read");
73
+ assert.equal(cached.allowed, true, "cached success served synchronously");
74
+ assert.equal(calls, 2, "evaluate() must reuse the cached success, no extra OPA call");
75
+ });
76
+ test("OpaPolicyEngine — http error caches a denial briefly so flapping OPA doesn't hammer", async () => {
77
+ let calls = 0;
78
+ const fetcher = (async (_u) => {
79
+ calls++;
80
+ return new Response("nope", { status: 503 });
81
+ });
82
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
83
+ const r1 = await e.warmEvaluate(["admin"], "sources", "read");
84
+ assert.equal(r1.allowed, false);
85
+ assert.match(r1.reason, /HTTP 503/);
86
+ // The next sync evaluate within ~1s uses the cached denial.
87
+ const r2 = e.evaluate(["admin"], "sources", "read");
88
+ assert.equal(r2.allowed, false);
89
+ assert.equal(calls, 1);
90
+ });
91
+ test("OpaPolicyEngine — list extracts permissions from the rich shape", async () => {
92
+ const fetcher = mockFetcher((_url, body) => {
93
+ if (body?.input?.list) {
94
+ return { permissions: [{ resource: "sources", action: "read" }, { resource: "sources", action: "write" }] };
95
+ }
96
+ return false;
97
+ });
98
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
99
+ const perms = await e.warmList(["admin"]);
100
+ assert.equal(perms.length, 2);
101
+ assert.deepEqual(perms[0], { resource: "sources", action: "read" });
102
+ });
103
+ test("OpaPolicyEngine — list returns [] when OPA returns plain boolean (no permissions key)", async () => {
104
+ const fetcher = mockFetcher(() => true);
105
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
106
+ const perms = await e.warmList(["admin"]);
107
+ assert.deepEqual(perms, []);
108
+ });
109
+ test("OpaPolicyEngine — roles() reflects declaredRoles", () => {
110
+ const e = new OpaPolicyEngine({
111
+ url: "http://opa.test", packagePath: "p",
112
+ declaredRoles: ["admin", "operator", "auditor"],
113
+ });
114
+ assert.deepEqual(e.roles(), ["admin", "operator", "auditor"]);
115
+ });
116
+ test("OpaPolicyEngine — kind() prefixes URL", () => {
117
+ const e = new OpaPolicyEngine({ url: "http://opa.example:8181", packagePath: "p" });
118
+ assert.equal(e.kind(), "opa:http://opa.example:8181");
119
+ });
120
+ test("OpaPolicyEngine — sends Bearer token when configured", async () => {
121
+ let seenAuth;
122
+ const fetcher = (async (_url, init) => {
123
+ seenAuth = init?.headers?.authorization;
124
+ return new Response(JSON.stringify({ result: true }), { status: 200 });
125
+ });
126
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", bearerToken: "shh", fetcher });
127
+ await e.warmEvaluate(["admin"], "sources", "read");
128
+ assert.equal(seenAuth, "Bearer shh");
129
+ });
130
+ test("OpaPolicyEngine — cache key delimiter prevents role-name collision (\"a,b\" vs [\"a\",\"b\"])", async () => {
131
+ const calls = [];
132
+ const fetcher = (async (_url, init) => {
133
+ calls.push(init?.body ? JSON.parse(String(init.body)) : null);
134
+ // Two distinct results so we can prove no cross-cache-hit.
135
+ return new Response(JSON.stringify({ result: calls.length === 1 }), { status: 200 });
136
+ });
137
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
138
+ const r1 = await e.warmEvaluate(["a,b"], "sources", "read");
139
+ const r2 = await e.warmEvaluate(["a", "b"], "sources", "read");
140
+ assert.equal(r1.allowed, true);
141
+ assert.equal(r2.allowed, false);
142
+ assert.equal(calls.length, 2, "the two role-sets must not collide on the cache key");
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
+ });
192
+ test("OpaPolicyEngine — sort-stable cache key (role-set order doesn't matter)", async () => {
193
+ let calls = 0;
194
+ const fetcher = (async (_u) => {
195
+ calls++;
196
+ return new Response(JSON.stringify({ result: true }), { status: 200 });
197
+ });
198
+ const e = new OpaPolicyEngine({ url: "http://opa.test", packagePath: "p", fetcher });
199
+ // Warm with one order; evaluate with another. The cache hit path
200
+ // (evaluate, not warmEvaluate — warm always re-queries) proves the
201
+ // key is order-independent: one OPA call, second is a cache hit.
202
+ await e.warmEvaluate(["b", "a", "c"], "sources", "read");
203
+ const cached = e.evaluate(["c", "b", "a"], "sources", "read");
204
+ assert.equal(calls, 1, "second evaluate (reordered roles) must reuse the cache, no extra fetch");
205
+ assert.equal(cached.allowed, true);
206
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Role-based access control for the management plane.
3
+ *
4
+ * Roles are simple string identifiers (`viewer`, `operator`, `admin` ship
5
+ * built-in but the resolver is open-set — a deployment may add any number
6
+ * of custom roles via OIDC group claims, the local users file, or future
7
+ * RBAC config).
8
+ *
9
+ * Permissions are encoded as `<resource>:<action>` pairs. The permission
10
+ * table maps each pair to the set of roles that are granted it. A role
11
+ * with no entries in the table grants nothing.
12
+ *
13
+ * Anonymous mode bypasses RBAC entirely — there is no identity to check.
14
+ * Basic mode runs every mutating /api/* request through `requirePermission`
15
+ * middleware (mounted in index.ts alongside `requireSession`).
16
+ */
17
+ import type { RequestHandler } from "express";
18
+ import type { AuthRuntime } from "./middleware.js";
19
+ import type { PolicyEngine } from "./policy/engine.js";
20
+ export type Action = "read" | "write" | "delete" | "bypass";
21
+ export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products";
22
+ export interface Permission {
23
+ resource: Resource;
24
+ action: Action;
25
+ }
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. */
31
+ export declare const DEFAULT_POLICY: Record<string, Permission[]>;
32
+ /** Resolve whether the given role set grants the requested permission. */
33
+ export declare function hasPermission(roles: string[] | undefined, resource: Resource, action: Action, policy?: Record<string, Permission[]>): boolean;
34
+ /**
35
+ * Express middleware factory. Returns a no-op in anonymous mode, otherwise
36
+ * gates the route on the named permission. Assumes `req.session` has been
37
+ * attached upstream by `buildSessionAttacher` and any auth requirement has
38
+ * already been enforced by `buildRequireSession` — so reaching this point
39
+ * with no session means anonymous mode is active.
40
+ */
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;
59
+ /** Convenience snapshot for `/api/me` — list every permission the
60
+ * given role set unlocks. Used by the UI to hide write controls the
61
+ * current user can't trigger anyway. */
62
+ export declare function listGrantedPermissions(roles: string[] | undefined, policy?: Record<string, Permission[]>): Permission[];