@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,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getProfile, profileNames, DEFAULT_PROFILE, } from "./profiles.js";
4
+ test("profiles: getProfile returns known profiles, case-insensitive", () => {
5
+ assert.equal(getProfile("github")?.name, "github");
6
+ assert.equal(getProfile("Github")?.name, "github");
7
+ assert.equal(getProfile("MICROSOFT-ENTRA")?.name, "microsoft-entra");
8
+ });
9
+ test("profiles: getProfile returns undefined for unknown / empty", () => {
10
+ assert.equal(getProfile(undefined), undefined);
11
+ assert.equal(getProfile(""), undefined);
12
+ assert.equal(getProfile("nope"), undefined);
13
+ });
14
+ test("profiles: profileNames lists the 6 baked-in profiles", () => {
15
+ const names = profileNames();
16
+ for (const expected of [
17
+ "generic",
18
+ "keycloak",
19
+ "github",
20
+ "google",
21
+ "microsoft-entra",
22
+ "okta",
23
+ ]) {
24
+ assert.ok(names.includes(expected), `missing profile ${expected}`);
25
+ }
26
+ });
27
+ test("profiles: DEFAULT_PROFILE is generic and preserves the pre-F6 defaults", () => {
28
+ assert.equal(DEFAULT_PROFILE.name, "generic");
29
+ assert.equal(DEFAULT_PROFILE.scopes, "openid profile email");
30
+ assert.equal(DEFAULT_PROFILE.rolesClaim, "groups");
31
+ assert.equal(DEFAULT_PROFILE.tenantClaim, "");
32
+ });
33
+ test("profiles: vendor-specific tenant claims match the IdP-native key", () => {
34
+ // hd = hosted domain (Google) — useful as a tenant key for
35
+ // multi-org Workspace deployments
36
+ assert.equal(getProfile("google")?.tenantClaim, "hd");
37
+ // tid = tenant id (Entra-native)
38
+ assert.equal(getProfile("microsoft-entra")?.tenantClaim, "tid");
39
+ });
40
+ test("profiles: Okta scopes include 'groups' so the claim is actually returned", () => {
41
+ // Okta's group claim is only emitted when 'groups' is in the
42
+ // requested scope set; profile must include it as a default.
43
+ assert.match(getProfile("okta")?.scopes ?? "", /\bgroups\b/);
44
+ });
45
+ test("profiles: each profile has a docs path", () => {
46
+ for (const name of profileNames()) {
47
+ const p = getProfile(name);
48
+ assert.ok(p.docs, `profile ${name} has no docs path`);
49
+ assert.match(p.docs, /^docs\//, `profile ${name} docs should be a repo-relative path`);
50
+ }
51
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Resolve the OIDC configuration from environment variables and turn
3
+ * it into the runtime shape the rest of the auth layer consumes.
4
+ *
5
+ * Mirrors the basic-mode resolution in src/index.ts: fail-closed on
6
+ * missing required config, allow an `OMCP_AUTH_ALLOW_FALLBACK=true`
7
+ * opt-out (handled by the caller — this module just signals via the
8
+ * `error` field).
9
+ *
10
+ * Required env (when OMCP_AUTH=oidc):
11
+ * OMCP_OIDC_ISSUER — IdP base URL (no trailing /.well-known/...)
12
+ * OMCP_OIDC_CLIENT_ID
13
+ * OMCP_OIDC_REDIRECT_URI — absolute, MUST match the registration
14
+ *
15
+ * Optional env:
16
+ * OMCP_OIDC_CLIENT_SECRET — confidential clients; public clients omit
17
+ * OMCP_OIDC_SCOPES — default "openid profile email"
18
+ * OMCP_OIDC_ROLES_CLAIM — dotted path; default "groups"
19
+ * OMCP_OIDC_ROLE_MAP — JSON {"<claim-value>": "<omcp-role>"};
20
+ * entries map directly to RBAC roles
21
+ * (viewer / operator / admin or custom).
22
+ * OMCP_OIDC_LOGOUT_REDIRECT — post-logout landing URL (default "/")
23
+ */
24
+ import { OidcClient } from "./client.js";
25
+ export interface OidcRuntimeConfig {
26
+ issuer: string;
27
+ clientId: string;
28
+ clientSecret?: string;
29
+ redirectUri: string;
30
+ scopes: string;
31
+ rolesClaim: string;
32
+ roleMap: Record<string, string>;
33
+ logoutRedirect: string;
34
+ /** Dotted claim path to read the tenant from. Empty / missing → all
35
+ * OIDC sessions land in the "default" tenant. */
36
+ tenantClaim: string;
37
+ /** Vendor profile id (generic / github / google / microsoft-entra /
38
+ * okta / keycloak) — surfaced in /api/info for diagnostics. */
39
+ profile?: string;
40
+ }
41
+ export interface ResolveOidcResult {
42
+ /** Fully validated runtime config; absent when `error` is set. */
43
+ config?: OidcRuntimeConfig;
44
+ /** Human-readable misconfiguration reason; used for the boot log
45
+ * + fail-closed exit. */
46
+ error?: string;
47
+ }
48
+ /** Pure env-to-config translator. No I/O. */
49
+ export declare function resolveOidcConfig(env?: NodeJS.ProcessEnv): ResolveOidcResult;
50
+ /** Build the OidcClient + the role-resolution helper from a resolved
51
+ * runtime config. Tests can stub OidcClient by passing a custom one. */
52
+ export interface OidcRuntime {
53
+ cfg: OidcRuntimeConfig;
54
+ client: OidcClient;
55
+ /** Walk a JWT claim set, follow the rolesClaim dotted path, and
56
+ * return the OMCP role names the user inherits via roleMap.
57
+ * Unknown claim values are silently dropped (least-privilege). */
58
+ resolveRoles(claims: Record<string, unknown>): string[];
59
+ /** Walk a JWT claim set, follow the configured tenant claim path,
60
+ * and return a normalised tenant id. Empty / missing / invalid →
61
+ * DEFAULT_TENANT. */
62
+ resolveTenant(claims: Record<string, unknown>): string;
63
+ }
64
+ export declare function buildOidcRuntime(cfg: OidcRuntimeConfig, opts?: {
65
+ client?: OidcClient;
66
+ }): OidcRuntime;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Resolve the OIDC configuration from environment variables and turn
3
+ * it into the runtime shape the rest of the auth layer consumes.
4
+ *
5
+ * Mirrors the basic-mode resolution in src/index.ts: fail-closed on
6
+ * missing required config, allow an `OMCP_AUTH_ALLOW_FALLBACK=true`
7
+ * opt-out (handled by the caller — this module just signals via the
8
+ * `error` field).
9
+ *
10
+ * Required env (when OMCP_AUTH=oidc):
11
+ * OMCP_OIDC_ISSUER — IdP base URL (no trailing /.well-known/...)
12
+ * OMCP_OIDC_CLIENT_ID
13
+ * OMCP_OIDC_REDIRECT_URI — absolute, MUST match the registration
14
+ *
15
+ * Optional env:
16
+ * OMCP_OIDC_CLIENT_SECRET — confidential clients; public clients omit
17
+ * OMCP_OIDC_SCOPES — default "openid profile email"
18
+ * OMCP_OIDC_ROLES_CLAIM — dotted path; default "groups"
19
+ * OMCP_OIDC_ROLE_MAP — JSON {"<claim-value>": "<omcp-role>"};
20
+ * entries map directly to RBAC roles
21
+ * (viewer / operator / admin or custom).
22
+ * OMCP_OIDC_LOGOUT_REDIRECT — post-logout landing URL (default "/")
23
+ */
24
+ import { OidcClient } from "./client.js";
25
+ import { DEFAULT_TENANT, tenantFromClaim } from "../../tenancy/context.js";
26
+ import { getProfile, DEFAULT_PROFILE } from "./profiles.js";
27
+ /** Pure env-to-config translator. No I/O. */
28
+ export function resolveOidcConfig(env = process.env) {
29
+ const issuer = nonEmpty(env.OMCP_OIDC_ISSUER);
30
+ const clientId = nonEmpty(env.OMCP_OIDC_CLIENT_ID);
31
+ const redirectUri = nonEmpty(env.OMCP_OIDC_REDIRECT_URI);
32
+ const missing = [];
33
+ if (!issuer)
34
+ missing.push("OMCP_OIDC_ISSUER");
35
+ if (!clientId)
36
+ missing.push("OMCP_OIDC_CLIENT_ID");
37
+ if (!redirectUri)
38
+ missing.push("OMCP_OIDC_REDIRECT_URI");
39
+ if (missing.length > 0) {
40
+ return { error: `OMCP_AUTH=oidc requires ${missing.join(", ")}` };
41
+ }
42
+ if (!/^https?:\/\//i.test(issuer)) {
43
+ return { error: `OMCP_OIDC_ISSUER must be an absolute http(s):// URL, got ${issuer}` };
44
+ }
45
+ if (!/^https?:\/\//i.test(redirectUri)) {
46
+ return { error: `OMCP_OIDC_REDIRECT_URI must be an absolute http(s):// URL, got ${redirectUri}` };
47
+ }
48
+ let roleMap = {};
49
+ const roleMapRaw = nonEmpty(env.OMCP_OIDC_ROLE_MAP);
50
+ if (roleMapRaw) {
51
+ try {
52
+ const parsed = JSON.parse(roleMapRaw);
53
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
54
+ return { error: "OMCP_OIDC_ROLE_MAP must be a JSON object of {\"claim-value\":\"omcp-role\"}" };
55
+ }
56
+ for (const [k, v] of Object.entries(parsed)) {
57
+ if (typeof v !== "string") {
58
+ return { error: `OMCP_OIDC_ROLE_MAP[${k}] must be a string, got ${typeof v}` };
59
+ }
60
+ roleMap[k] = v;
61
+ }
62
+ }
63
+ catch (e) {
64
+ return { error: `OMCP_OIDC_ROLE_MAP is not valid JSON: ${e.message}` };
65
+ }
66
+ }
67
+ // Vendor profile resolves IdP-shaped defaults (scopes / rolesClaim /
68
+ // tenantClaim) when OMCP_OIDC_PROFILE is set. Explicit env vars
69
+ // always win — profiles only fill the gaps an operator chose not to
70
+ // set, so this is fully backwards-compatible with pre-F6 configs.
71
+ const profileName = nonEmpty(env.OMCP_OIDC_PROFILE);
72
+ const profile = (profileName && getProfile(profileName)) || DEFAULT_PROFILE;
73
+ if (profileName && !getProfile(profileName)) {
74
+ return {
75
+ error: `OMCP_OIDC_PROFILE=${profileName} is not a known profile (try one of: generic, keycloak, github, google, microsoft-entra, okta)`,
76
+ };
77
+ }
78
+ return {
79
+ config: {
80
+ issuer: issuer.replace(/\/$/, ""),
81
+ clientId: clientId,
82
+ clientSecret: nonEmpty(env.OMCP_OIDC_CLIENT_SECRET),
83
+ redirectUri: redirectUri,
84
+ scopes: nonEmpty(env.OMCP_OIDC_SCOPES) ?? profile.scopes,
85
+ rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ?? profile.rolesClaim,
86
+ roleMap,
87
+ logoutRedirect: nonEmpty(env.OMCP_OIDC_LOGOUT_REDIRECT) ?? "/",
88
+ tenantClaim: nonEmpty(env.OMCP_OIDC_TENANT_CLAIM) ?? profile.tenantClaim,
89
+ profile: profile.name,
90
+ },
91
+ };
92
+ }
93
+ export function buildOidcRuntime(cfg, opts = {}) {
94
+ const client = opts.client ?? new OidcClient({
95
+ issuer: cfg.issuer,
96
+ clientId: cfg.clientId,
97
+ clientSecret: cfg.clientSecret,
98
+ redirectUri: cfg.redirectUri,
99
+ scopes: cfg.scopes,
100
+ });
101
+ return {
102
+ cfg,
103
+ client,
104
+ resolveRoles(claims) {
105
+ const raw = lookupClaim(claims, cfg.rolesClaim);
106
+ const values = Array.isArray(raw)
107
+ ? raw.filter((v) => typeof v === "string")
108
+ : typeof raw === "string"
109
+ ? [raw]
110
+ : [];
111
+ const roles = new Set();
112
+ for (const v of values) {
113
+ const mapped = cfg.roleMap[v];
114
+ if (mapped)
115
+ roles.add(mapped);
116
+ }
117
+ return [...roles];
118
+ },
119
+ resolveTenant(claims) {
120
+ return cfg.tenantClaim ? tenantFromClaim(claims, cfg.tenantClaim) : DEFAULT_TENANT;
121
+ },
122
+ };
123
+ }
124
+ function nonEmpty(v) {
125
+ if (v === undefined)
126
+ return undefined;
127
+ const t = v.trim();
128
+ return t.length > 0 ? t : undefined;
129
+ }
130
+ function lookupClaim(claims, dottedPath) {
131
+ const parts = dottedPath.split(".");
132
+ let cur = claims;
133
+ for (const p of parts) {
134
+ if (cur && typeof cur === "object" && !Array.isArray(cur) && p in cur) {
135
+ cur = cur[p];
136
+ }
137
+ else {
138
+ return undefined;
139
+ }
140
+ }
141
+ return cur;
142
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,181 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveOidcConfig, buildOidcRuntime } from "./runtime.js";
4
+ function envOf(overrides) {
5
+ return { ...overrides };
6
+ }
7
+ test("resolveOidcConfig — happy path with required vars only", () => {
8
+ const r = resolveOidcConfig(envOf({
9
+ OMCP_OIDC_ISSUER: "https://idp.test",
10
+ OMCP_OIDC_CLIENT_ID: "c-1",
11
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
12
+ }));
13
+ assert.equal(r.error, undefined);
14
+ assert.deepEqual(r.config, {
15
+ issuer: "https://idp.test",
16
+ clientId: "c-1",
17
+ clientSecret: undefined,
18
+ redirectUri: "https://app.test/cb",
19
+ scopes: "openid profile email",
20
+ rolesClaim: "groups",
21
+ roleMap: {},
22
+ logoutRedirect: "/",
23
+ tenantClaim: "",
24
+ profile: "generic",
25
+ });
26
+ });
27
+ test("resolveOidcConfig — honours OMCP_OIDC_TENANT_CLAIM", () => {
28
+ const r = resolveOidcConfig(envOf({
29
+ OMCP_OIDC_ISSUER: "https://idp.test",
30
+ OMCP_OIDC_CLIENT_ID: "c-1",
31
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
32
+ OMCP_OIDC_TENANT_CLAIM: "app.tenant_id",
33
+ }));
34
+ assert.equal(r.config?.tenantClaim, "app.tenant_id");
35
+ });
36
+ test("buildOidcRuntime.resolveTenant — empty claim path → default", () => {
37
+ const r = resolveOidcConfig(envOf({
38
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
39
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
40
+ }));
41
+ const rt = buildOidcRuntime(r.config);
42
+ assert.equal(rt.resolveTenant({ tenant: "acme" }), "default");
43
+ });
44
+ test("buildOidcRuntime.resolveTenant — dotted path", () => {
45
+ const r = resolveOidcConfig(envOf({
46
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
47
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
48
+ OMCP_OIDC_TENANT_CLAIM: "app.tenant_id",
49
+ }));
50
+ const rt = buildOidcRuntime(r.config);
51
+ assert.equal(rt.resolveTenant({ app: { tenant_id: "acme" } }), "acme");
52
+ assert.equal(rt.resolveTenant({ app: { other: "x" } }), "default");
53
+ });
54
+ test("resolveOidcConfig — surfaces ALL missing required vars in one message", () => {
55
+ const r = resolveOidcConfig(envOf({}));
56
+ assert.match(r.error ?? "", /OMCP_OIDC_ISSUER.*OMCP_OIDC_CLIENT_ID.*OMCP_OIDC_REDIRECT_URI/);
57
+ assert.equal(r.config, undefined);
58
+ });
59
+ test("resolveOidcConfig — rejects non-URL issuer / redirect", () => {
60
+ const r1 = resolveOidcConfig(envOf({
61
+ OMCP_OIDC_ISSUER: "idp.test",
62
+ OMCP_OIDC_CLIENT_ID: "c", OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
63
+ }));
64
+ assert.match(r1.error ?? "", /OMCP_OIDC_ISSUER must be an absolute/);
65
+ const r2 = resolveOidcConfig(envOf({
66
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
67
+ OMCP_OIDC_REDIRECT_URI: "/cb",
68
+ }));
69
+ assert.match(r2.error ?? "", /OMCP_OIDC_REDIRECT_URI must be an absolute/);
70
+ });
71
+ test("resolveOidcConfig — strips a single trailing slash off issuer", () => {
72
+ const r = resolveOidcConfig(envOf({
73
+ OMCP_OIDC_ISSUER: "https://idp.test/",
74
+ OMCP_OIDC_CLIENT_ID: "c", OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
75
+ }));
76
+ assert.equal(r.config?.issuer, "https://idp.test");
77
+ });
78
+ test("resolveOidcConfig — empty strings count as missing", () => {
79
+ const r = resolveOidcConfig(envOf({
80
+ OMCP_OIDC_ISSUER: " ",
81
+ OMCP_OIDC_CLIENT_ID: "",
82
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
83
+ }));
84
+ assert.match(r.error ?? "", /OMCP_OIDC_ISSUER.*OMCP_OIDC_CLIENT_ID/);
85
+ });
86
+ test("resolveOidcConfig — parses OMCP_OIDC_ROLE_MAP JSON", () => {
87
+ const r = resolveOidcConfig(envOf({
88
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
89
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
90
+ OMCP_OIDC_ROLE_MAP: '{"omcp-admin":"admin","omcp-ops":"operator","omcp-viewers":"viewer"}',
91
+ }));
92
+ assert.deepEqual(r.config?.roleMap, { "omcp-admin": "admin", "omcp-ops": "operator", "omcp-viewers": "viewer" });
93
+ });
94
+ test("resolveOidcConfig — rejects malformed OMCP_OIDC_ROLE_MAP", () => {
95
+ const bad = resolveOidcConfig(envOf({
96
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
97
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
98
+ OMCP_OIDC_ROLE_MAP: "not json",
99
+ }));
100
+ assert.match(bad.error ?? "", /not valid JSON/);
101
+ const wrongType = resolveOidcConfig(envOf({
102
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
103
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
104
+ OMCP_OIDC_ROLE_MAP: '["arr"]',
105
+ }));
106
+ assert.match(wrongType.error ?? "", /JSON object/);
107
+ const wrongValue = resolveOidcConfig(envOf({
108
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
109
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
110
+ OMCP_OIDC_ROLE_MAP: '{"x": 1}',
111
+ }));
112
+ assert.match(wrongValue.error ?? "", /must be a string/);
113
+ });
114
+ test("resolveOidcConfig — propagates OMCP_OIDC_CLIENT_SECRET (confidential client)", () => {
115
+ const r = resolveOidcConfig(envOf({
116
+ OMCP_OIDC_ISSUER: "https://idp.test",
117
+ OMCP_OIDC_CLIENT_ID: "c-1",
118
+ OMCP_OIDC_CLIENT_SECRET: "confidential-shared-secret",
119
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
120
+ }));
121
+ assert.equal(r.config?.clientSecret, "confidential-shared-secret");
122
+ });
123
+ test("resolveOidcConfig — honours OMCP_OIDC_SCOPES / ROLES_CLAIM / LOGOUT_REDIRECT", () => {
124
+ const r = resolveOidcConfig(envOf({
125
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
126
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
127
+ OMCP_OIDC_SCOPES: "openid email custom-scope",
128
+ OMCP_OIDC_ROLES_CLAIM: "realm_access.roles",
129
+ OMCP_OIDC_LOGOUT_REDIRECT: "https://app.test/bye",
130
+ }));
131
+ assert.equal(r.config?.scopes, "openid email custom-scope");
132
+ assert.equal(r.config?.rolesClaim, "realm_access.roles");
133
+ assert.equal(r.config?.logoutRedirect, "https://app.test/bye");
134
+ });
135
+ test("buildOidcRuntime.resolveRoles — flat groups claim with array value", () => {
136
+ const r = resolveOidcConfig(envOf({
137
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
138
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
139
+ OMCP_OIDC_ROLE_MAP: '{"omcp-admin":"admin","omcp-ops":"operator","other":"viewer"}',
140
+ }));
141
+ const rt = buildOidcRuntime(r.config);
142
+ assert.deepEqual(rt.resolveRoles({ groups: ["omcp-admin", "unknown", "omcp-ops"] }).sort(), ["admin", "operator"]);
143
+ });
144
+ test("buildOidcRuntime.resolveRoles — dotted claim path (Keycloak realm_access.roles shape)", () => {
145
+ const r = resolveOidcConfig(envOf({
146
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
147
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
148
+ OMCP_OIDC_ROLES_CLAIM: "realm_access.roles",
149
+ OMCP_OIDC_ROLE_MAP: '{"omcp-admin":"admin"}',
150
+ }));
151
+ const rt = buildOidcRuntime(r.config);
152
+ assert.deepEqual(rt.resolveRoles({ realm_access: { roles: ["omcp-admin"] } }), ["admin"]);
153
+ });
154
+ test("buildOidcRuntime.resolveRoles — scalar string claim value still maps", () => {
155
+ const r = resolveOidcConfig(envOf({
156
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
157
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
158
+ OMCP_OIDC_ROLES_CLAIM: "role",
159
+ OMCP_OIDC_ROLE_MAP: '{"sso-admin":"admin"}',
160
+ }));
161
+ const rt = buildOidcRuntime(r.config);
162
+ assert.deepEqual(rt.resolveRoles({ role: "sso-admin" }), ["admin"]);
163
+ });
164
+ test("buildOidcRuntime.resolveRoles — missing claim path yields empty roles (least privilege)", () => {
165
+ const r = resolveOidcConfig(envOf({
166
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
167
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
168
+ OMCP_OIDC_ROLE_MAP: '{"x":"admin"}',
169
+ }));
170
+ const rt = buildOidcRuntime(r.config);
171
+ assert.deepEqual(rt.resolveRoles({ sub: "alice" }), []);
172
+ });
173
+ test("buildOidcRuntime.resolveRoles — deduplicates when multiple claim values map to same role", () => {
174
+ const r = resolveOidcConfig(envOf({
175
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
176
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
177
+ OMCP_OIDC_ROLE_MAP: '{"a":"admin","b":"admin"}',
178
+ }));
179
+ const rt = buildOidcRuntime(r.config);
180
+ assert.deepEqual(rt.resolveRoles({ groups: ["a", "b"] }), ["admin"]);
181
+ });
@@ -0,0 +1,56 @@
1
+ import type { PolicyEngine } from "./engine.js";
2
+ export interface BatchSubject {
3
+ /** Human-readable identifier echoed in the response (UI heat-map
4
+ * row label). Usually `<name>@<tenant>` or a group name. */
5
+ key: string;
6
+ /** Roles the subject would have at evaluation time. */
7
+ roles: string[];
8
+ /** Tenant the subject is acting under. Optional; defaults to the
9
+ * caller's session tenant at the handler level. */
10
+ tenant?: string;
11
+ }
12
+ export interface BatchDryRunRequest {
13
+ subjects: BatchSubject[];
14
+ /** Resources to probe — should match VALID_RESOURCES on the
15
+ * active engine. Unknown resources are dropped with a note. */
16
+ resources: string[];
17
+ /** Actions to probe — should match VALID_ACTIONS. */
18
+ actions: string[];
19
+ }
20
+ export interface BatchCellVerdict {
21
+ allowed: boolean;
22
+ reason?: string;
23
+ }
24
+ export interface BatchDryRunResult {
25
+ /** result[subjectKey][resource][action] = { allowed, reason } */
26
+ matrix: Record<string, Record<string, Record<string, BatchCellVerdict>>>;
27
+ /** Anything the handler skipped: bad resource, bad action, too
28
+ * many cells, etc. Helps the operator fix the next batch quickly. */
29
+ dropped: Array<{
30
+ kind: "resource" | "action" | "subject" | "cap";
31
+ value: string;
32
+ reason: string;
33
+ }>;
34
+ /** Summary counts to power the UI's headline stats. */
35
+ totals: {
36
+ cells: number;
37
+ allow: number;
38
+ deny: number;
39
+ };
40
+ }
41
+ export interface BatchLimits {
42
+ maxSubjects: number;
43
+ maxResources: number;
44
+ maxActions: number;
45
+ }
46
+ export declare const DEFAULT_BATCH_LIMITS: BatchLimits;
47
+ /**
48
+ * Run a batch dry-run against the policy engine. The engine is
49
+ * called once per cell — for the BuiltinPolicyEngine this is pure
50
+ * compute and cheap; for the OPA engine it's one Rego query per
51
+ * cell. The handler caps the matrix so a careless caller can't DoS
52
+ * an external OPA.
53
+ */
54
+ export declare function evaluateBatch(engine: PolicyEngine, req: BatchDryRunRequest, validResources: ReadonlySet<string>, validActions: ReadonlySet<string>, limits?: BatchLimits): Promise<BatchDryRunResult>;
55
+ /** Turn a batch result into CSV — `subject,resource,action,allowed,reason`. */
56
+ export declare function batchResultToCsv(result: BatchDryRunResult): string;
@@ -0,0 +1,129 @@
1
+ // Batch policy dry-run — Phase F16.
2
+ //
3
+ // The existing single-call dry-run probe (`GET /api/policy?roles=…
4
+ // &resource=…&action=…`) is great for "why did this one call fail"
5
+ // but doesn't scale to a security-review session reviewing a
6
+ // proposed role change. F16 adds a batch variant that evaluates
7
+ // every (subject × resource × action) combination in one pass and
8
+ // returns a matrix the UI can render as a heat-map.
9
+ //
10
+ // The handler stays out of the route file — pure compute is easier
11
+ // to unit-test and easier to reuse from a CI policy-diff job later.
12
+ export const DEFAULT_BATCH_LIMITS = {
13
+ maxSubjects: 100,
14
+ maxResources: 100,
15
+ maxActions: 10,
16
+ };
17
+ /**
18
+ * Run a batch dry-run against the policy engine. The engine is
19
+ * called once per cell — for the BuiltinPolicyEngine this is pure
20
+ * compute and cheap; for the OPA engine it's one Rego query per
21
+ * cell. The handler caps the matrix so a careless caller can't DoS
22
+ * an external OPA.
23
+ */
24
+ export async function evaluateBatch(engine, req, validResources, validActions, limits = DEFAULT_BATCH_LIMITS) {
25
+ const dropped = [];
26
+ // De-duplicate inputs; preserve first-seen order.
27
+ const seenSubjectKeys = new Set();
28
+ const subjects = [];
29
+ for (const s of req.subjects ?? []) {
30
+ if (!s || typeof s.key !== "string" || !Array.isArray(s.roles)) {
31
+ dropped.push({ kind: "subject", value: String(s?.key ?? "<malformed>"), reason: "missing key or roles[]" });
32
+ continue;
33
+ }
34
+ if (seenSubjectKeys.has(s.key))
35
+ continue;
36
+ seenSubjectKeys.add(s.key);
37
+ subjects.push(s);
38
+ }
39
+ const resources = unique(req.resources ?? []).filter((r) => {
40
+ if (!validResources.has(r)) {
41
+ dropped.push({ kind: "resource", value: r, reason: "not in active engine's VALID_RESOURCES" });
42
+ return false;
43
+ }
44
+ return true;
45
+ });
46
+ const actions = unique(req.actions ?? []).filter((a) => {
47
+ if (!validActions.has(a)) {
48
+ dropped.push({ kind: "action", value: a, reason: "not in active engine's VALID_ACTIONS" });
49
+ return false;
50
+ }
51
+ return true;
52
+ });
53
+ // Cap enforcement — favour clear-cap-error over partial silent results.
54
+ if (subjects.length > limits.maxSubjects) {
55
+ dropped.push({ kind: "cap", value: `subjects=${subjects.length}`, reason: `truncated to ${limits.maxSubjects} (cap)` });
56
+ subjects.length = limits.maxSubjects;
57
+ }
58
+ if (resources.length > limits.maxResources) {
59
+ dropped.push({ kind: "cap", value: `resources=${resources.length}`, reason: `truncated to ${limits.maxResources} (cap)` });
60
+ resources.length = limits.maxResources;
61
+ }
62
+ if (actions.length > limits.maxActions) {
63
+ dropped.push({ kind: "cap", value: `actions=${actions.length}`, reason: `truncated to ${limits.maxActions} (cap)` });
64
+ actions.length = limits.maxActions;
65
+ }
66
+ const matrix = {};
67
+ let allowCount = 0;
68
+ let denyCount = 0;
69
+ for (const s of subjects) {
70
+ matrix[s.key] = {};
71
+ for (const r of resources) {
72
+ matrix[s.key][r] = {};
73
+ for (const a of actions) {
74
+ const verdict = await Promise.resolve(engine.evaluate(s.roles, r, a, s.tenant ? { tenant: s.tenant } : undefined));
75
+ matrix[s.key][r][a] = {
76
+ allowed: verdict.allowed,
77
+ reason: verdict.reason,
78
+ };
79
+ if (verdict.allowed)
80
+ allowCount += 1;
81
+ else
82
+ denyCount += 1;
83
+ }
84
+ }
85
+ }
86
+ return {
87
+ matrix,
88
+ dropped,
89
+ totals: {
90
+ cells: subjects.length * resources.length * actions.length,
91
+ allow: allowCount,
92
+ deny: denyCount,
93
+ },
94
+ };
95
+ }
96
+ function unique(xs) {
97
+ const seen = new Set();
98
+ const out = [];
99
+ for (const x of xs) {
100
+ if (seen.has(x))
101
+ continue;
102
+ seen.add(x);
103
+ out.push(x);
104
+ }
105
+ return out;
106
+ }
107
+ /** Turn a batch result into CSV — `subject,resource,action,allowed,reason`. */
108
+ export function batchResultToCsv(result) {
109
+ const lines = ["subject,resource,action,allowed,reason"];
110
+ for (const [subject, perResource] of Object.entries(result.matrix)) {
111
+ for (const [resource, perAction] of Object.entries(perResource)) {
112
+ for (const [action, verdict] of Object.entries(perAction)) {
113
+ lines.push([
114
+ csvEscape(subject),
115
+ csvEscape(resource),
116
+ csvEscape(action),
117
+ verdict.allowed ? "allow" : "deny",
118
+ csvEscape(verdict.reason ?? ""),
119
+ ].join(","));
120
+ }
121
+ }
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ function csvEscape(v) {
126
+ if (/[",\n\r]/.test(v))
127
+ return `"${v.replace(/"/g, '""')}"`;
128
+ return v;
129
+ }
@@ -0,0 +1 @@
1
+ export {};