@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,162 @@
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
+ /** Built-in default policy. Operators replace this via OMCP_RBAC_POLICY_FILE
18
+ * (YAML/JSON file → BuiltinPolicyEngine) or OMCP_OPA_URL (external
19
+ * OPA → OpaPolicyEngine). The gate consumes whichever via
20
+ * `buildRequirePermissionFromEngine` so tenant-conditional Rego
21
+ * rules can fire. */
22
+ export const DEFAULT_POLICY = {
23
+ viewer: [
24
+ { resource: "sources", action: "read" },
25
+ { resource: "services", action: "read" },
26
+ { resource: "health", action: "read" },
27
+ { resource: "topology", action: "read" },
28
+ { resource: "settings", action: "read" },
29
+ { resource: "connectors", action: "read" },
30
+ { resource: "audit", action: "read" },
31
+ { resource: "catalog", action: "read" },
32
+ { resource: "products", action: "read" },
33
+ ],
34
+ operator: [
35
+ // Inherits viewer's read set + write on operational resources.
36
+ { resource: "sources", action: "read" },
37
+ { resource: "sources", action: "write" },
38
+ { resource: "services", action: "read" },
39
+ { resource: "health", action: "read" },
40
+ { resource: "health", action: "write" },
41
+ { resource: "topology", action: "read" },
42
+ { resource: "settings", action: "read" },
43
+ { resource: "settings", action: "write" },
44
+ { resource: "connectors", action: "read" },
45
+ { resource: "audit", action: "read" },
46
+ { resource: "catalog", action: "read" },
47
+ { resource: "products", action: "read" },
48
+ { resource: "products", action: "write" },
49
+ ],
50
+ admin: [
51
+ // Full surface — readable + writable + deletable.
52
+ ...["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products"]
53
+ .flatMap((r) => ["read", "write", "delete"].map((a) => ({ resource: r, action: a }))),
54
+ // Special: admins may bypass log-redaction on per-call MCP tool
55
+ // invocations (when the bearer credential ALSO opts in via
56
+ // OMCP_KEY_BYPASS_REDACTION — RBAC is the management-plane gate,
57
+ // the credential flag is the data-plane gate; both must allow).
58
+ { resource: "redaction", action: "bypass" },
59
+ ],
60
+ };
61
+ /** Resolve whether the given role set grants the requested permission. */
62
+ export function hasPermission(roles, resource, action, policy = DEFAULT_POLICY) {
63
+ if (!roles || roles.length === 0)
64
+ return false;
65
+ for (const role of roles) {
66
+ const grants = policy[role];
67
+ if (!grants)
68
+ continue;
69
+ for (const g of grants) {
70
+ if (g.resource === resource && g.action === action)
71
+ return true;
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+ /**
77
+ * Express middleware factory. Returns a no-op in anonymous mode, otherwise
78
+ * gates the route on the named permission. Assumes `req.session` has been
79
+ * attached upstream by `buildSessionAttacher` and any auth requirement has
80
+ * already been enforced by `buildRequireSession` — so reaching this point
81
+ * with no session means anonymous mode is active.
82
+ */
83
+ export function buildRequirePermission(runtime, resource, action, policy = DEFAULT_POLICY) {
84
+ if (runtime.mode === "anonymous") {
85
+ return function rbacNoop(_req, _res, next) {
86
+ next();
87
+ };
88
+ }
89
+ return function rbacGate(req, res, next) {
90
+ const roles = req.session?.roles;
91
+ if (hasPermission(roles, resource, action, policy)) {
92
+ next();
93
+ return;
94
+ }
95
+ res.status(403).json({
96
+ error: "permission denied",
97
+ code: "OMCP_PERMISSION_DENIED",
98
+ required: { resource, action },
99
+ have: roles ?? [],
100
+ });
101
+ };
102
+ }
103
+ /**
104
+ * Engine-aware variant of `buildRequirePermission`. Prefer this when an
105
+ * external policy engine (OPA, custom Rego) is in play — the legacy
106
+ * `(roles, resource, action) → boolean` map cannot carry the active
107
+ * tenant, so a Rego rule like `allow { input.tenant == "acme" }` can
108
+ * never fire if you go through the map. This variant calls
109
+ * `engine.evaluate(roles, resource, action, { tenant: session.tenant })`
110
+ * so tenant-conditional rules see the input they need.
111
+ *
112
+ * Anonymous mode stays a no-op — same as the map variant.
113
+ *
114
+ * Performance: `evaluate` is sync by contract. OPA hits its cache; on
115
+ * first miss it returns a conservative deny and warms in the
116
+ * background — the second request inside the TTL gets the real
117
+ * verdict. Documented in opa.ts.
118
+ */
119
+ export function buildRequirePermissionFromEngine(runtime, resource, action, engine) {
120
+ if (runtime.mode === "anonymous") {
121
+ return function rbacNoop(_req, _res, next) {
122
+ next();
123
+ };
124
+ }
125
+ return function rbacGateEngine(req, res, next) {
126
+ const sess = req.session;
127
+ const verdict = engine.evaluate(sess?.roles, resource, action, { tenant: sess?.tenant });
128
+ if (verdict.allowed) {
129
+ next();
130
+ return;
131
+ }
132
+ res.status(403).json({
133
+ error: "permission denied",
134
+ code: "OMCP_PERMISSION_DENIED",
135
+ required: { resource, action },
136
+ have: sess?.roles ?? [],
137
+ reason: verdict.reason,
138
+ });
139
+ };
140
+ }
141
+ /** Convenience snapshot for `/api/me` — list every permission the
142
+ * given role set unlocks. Used by the UI to hide write controls the
143
+ * current user can't trigger anyway. */
144
+ export function listGrantedPermissions(roles, policy = DEFAULT_POLICY) {
145
+ if (!roles || roles.length === 0)
146
+ return [];
147
+ const seen = new Set();
148
+ const out = [];
149
+ for (const role of roles) {
150
+ const grants = policy[role];
151
+ if (!grants)
152
+ continue;
153
+ for (const g of grants) {
154
+ const key = `${g.resource}:${g.action}`;
155
+ if (seen.has(key))
156
+ continue;
157
+ seen.add(key);
158
+ out.push(g);
159
+ }
160
+ }
161
+ return out;
162
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { hasPermission, buildRequirePermission, listGrantedPermissions, DEFAULT_POLICY, } from "./rbac.js";
4
+ function mkReq(roles) {
5
+ return {
6
+ session: roles
7
+ ? { sub: "u", name: "u", roles, iat: 0, exp: Date.now() / 1000 + 60 }
8
+ : undefined,
9
+ };
10
+ }
11
+ function mkRes() {
12
+ let statusCode = 0;
13
+ let body = null;
14
+ return {
15
+ status(c) { statusCode = c; return this; },
16
+ json(b) { body = b; return this; },
17
+ get statusCode() { return statusCode; },
18
+ get body() { return body; },
19
+ };
20
+ }
21
+ test("DEFAULT_POLICY — viewer reads but cannot write", () => {
22
+ assert.equal(hasPermission(["viewer"], "sources", "read"), true);
23
+ assert.equal(hasPermission(["viewer"], "sources", "write"), false);
24
+ assert.equal(hasPermission(["viewer"], "sources", "delete"), false);
25
+ });
26
+ test("DEFAULT_POLICY — operator writes sources + settings but never deletes", () => {
27
+ assert.equal(hasPermission(["operator"], "sources", "write"), true);
28
+ assert.equal(hasPermission(["operator"], "settings", "write"), true);
29
+ assert.equal(hasPermission(["operator"], "sources", "delete"), false);
30
+ });
31
+ test("DEFAULT_POLICY — admin can do everything across every resource", () => {
32
+ for (const resource of ["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products"]) {
33
+ for (const action of ["read", "write", "delete"]) {
34
+ assert.equal(hasPermission(["admin"], resource, action), true, `admin should ${action} ${resource}`);
35
+ }
36
+ }
37
+ });
38
+ test("DEFAULT_POLICY — only admin holds redaction:bypass", () => {
39
+ assert.equal(hasPermission(["admin"], "redaction", "bypass"), true);
40
+ assert.equal(hasPermission(["operator"], "redaction", "bypass"), false);
41
+ assert.equal(hasPermission(["viewer"], "redaction", "bypass"), false);
42
+ // Other actions on the redaction resource are intentionally NOT granted —
43
+ // there is no read/write/delete semantic, only bypass.
44
+ assert.equal(hasPermission(["admin"], "redaction", "read"), false);
45
+ assert.equal(hasPermission(["admin"], "redaction", "write"), false);
46
+ });
47
+ test("hasPermission — empty / missing roles grant nothing", () => {
48
+ assert.equal(hasPermission(undefined, "sources", "read"), false);
49
+ assert.equal(hasPermission([], "sources", "read"), false);
50
+ assert.equal(hasPermission(["unknown-role"], "sources", "read"), false);
51
+ });
52
+ test("hasPermission — union of roles is honoured", () => {
53
+ // A user assigned both viewer and operator gets the operator superset.
54
+ assert.equal(hasPermission(["viewer", "operator"], "sources", "write"), true);
55
+ });
56
+ test("hasPermission — custom policy overrides built-in", () => {
57
+ const custom = {
58
+ "incident-commander": [{ resource: "sources", action: "delete" }],
59
+ };
60
+ assert.equal(hasPermission(["incident-commander"], "sources", "delete", custom), true);
61
+ // Built-in admin is NOT in the custom policy → loses its grants.
62
+ assert.equal(hasPermission(["admin"], "sources", "delete", custom), false);
63
+ });
64
+ test("buildRequirePermission — anonymous always allows", () => {
65
+ const mw = buildRequirePermission({ mode: "anonymous" }, "sources", "write");
66
+ const res = mkRes();
67
+ let called = false;
68
+ mw(mkReq(), res, () => { called = true; });
69
+ assert.equal(called, true);
70
+ assert.equal(res.statusCode, 0);
71
+ });
72
+ test("buildRequirePermission — denies viewer on write", () => {
73
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
74
+ const mw = buildRequirePermission(runtime, "sources", "write");
75
+ const res = mkRes();
76
+ let called = false;
77
+ mw(mkReq(["viewer"]), res, () => { called = true; });
78
+ assert.equal(called, false);
79
+ assert.equal(res.statusCode, 403);
80
+ const body = res.body;
81
+ assert.equal(body.code, "OMCP_PERMISSION_DENIED");
82
+ });
83
+ test("buildRequirePermission — allows operator on write", () => {
84
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
85
+ const mw = buildRequirePermission(runtime, "sources", "write");
86
+ const res = mkRes();
87
+ let called = false;
88
+ mw(mkReq(["operator"]), res, () => { called = true; });
89
+ assert.equal(called, true);
90
+ assert.equal(res.statusCode, 0);
91
+ });
92
+ test("buildRequirePermission — denies missing session in basic mode", () => {
93
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
94
+ const mw = buildRequirePermission(runtime, "sources", "read");
95
+ const res = mkRes();
96
+ let called = false;
97
+ mw(mkReq(), res, () => { called = true; });
98
+ assert.equal(called, false);
99
+ assert.equal(res.statusCode, 403);
100
+ });
101
+ test("listGrantedPermissions — deduplicates across overlapping roles", () => {
102
+ const p = listGrantedPermissions(["viewer", "operator"]);
103
+ const keys = p.map((g) => `${g.resource}:${g.action}`);
104
+ assert.equal(new Set(keys).size, keys.length, "expected no duplicates");
105
+ // sanity: includes both a viewer-only read and an operator-only write
106
+ assert.ok(p.some((g) => g.resource === "sources" && g.action === "read"));
107
+ assert.ok(p.some((g) => g.resource === "sources" && g.action === "write"));
108
+ });
109
+ test("listGrantedPermissions — admin lists every (resource, action) once", () => {
110
+ const p = listGrantedPermissions(["admin"]);
111
+ // 10 resources (added 'products' in the products-RBAC phase) * 3 actions
112
+ // = 30, plus the special redaction:bypass entry = 31.
113
+ assert.equal(p.length, 31);
114
+ assert.ok(p.some((g) => g.resource === "redaction" && g.action === "bypass"));
115
+ assert.ok(p.some((g) => g.resource === "products" && g.action === "delete"));
116
+ });
117
+ test("DEFAULT_POLICY shape — has the three built-in roles", () => {
118
+ assert.ok(DEFAULT_POLICY.viewer);
119
+ assert.ok(DEFAULT_POLICY.operator);
120
+ assert.ok(DEFAULT_POLICY.admin);
121
+ });
122
+ import { buildRequirePermissionFromEngine } from "./rbac.js";
123
+ import { BuiltinPolicyEngine } from "./policy/engine.js";
124
+ test("buildRequirePermissionFromEngine — anonymous always allows (no-op middleware)", () => {
125
+ const engine = new BuiltinPolicyEngine(DEFAULT_POLICY);
126
+ const mw = buildRequirePermissionFromEngine({ mode: "anonymous" }, "sources", "write", engine);
127
+ const res = mkRes();
128
+ let called = false;
129
+ mw(mkReq(), res, () => { called = true; });
130
+ assert.equal(called, true);
131
+ assert.equal(res.statusCode, 0);
132
+ });
133
+ test("buildRequirePermissionFromEngine — engine.evaluate verdict is surfaced verbatim in the 403 body", () => {
134
+ // Spy engine returns a custom reason so we can prove the gate forwards it.
135
+ const engine = {
136
+ evaluate: () => ({ allowed: false, reason: "test deny reason from engine" }),
137
+ list: () => [],
138
+ roles: () => [],
139
+ kind: () => "test",
140
+ };
141
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
142
+ const mw = buildRequirePermissionFromEngine(runtime, "sources", "write", engine);
143
+ const res = mkRes();
144
+ mw(mkReq(["viewer"]), res, () => undefined);
145
+ assert.equal(res.statusCode, 403);
146
+ const body = res.body;
147
+ assert.equal(body.code, "OMCP_PERMISSION_DENIED");
148
+ assert.equal(body.reason, "test deny reason from engine");
149
+ });
150
+ test("buildRequirePermissionFromEngine — passes session.tenant into engine.evaluate's EvalContext (load-bearing for OPA tenant rules)", () => {
151
+ // This is the property the OPA-input-tenant refine relies on:
152
+ // the gate MUST pass session.tenant to engine.evaluate so a Rego
153
+ // rule like `allow { input.tenant == "acme" }` can fire. Capture
154
+ // the ctx and assert it carries the tenant verbatim.
155
+ let seenCtx;
156
+ const engine = {
157
+ evaluate: (_roles, _r, _a, ctx) => {
158
+ seenCtx = ctx;
159
+ return { allowed: true, reason: "ok" };
160
+ },
161
+ list: () => [],
162
+ roles: () => [],
163
+ kind: () => "spy",
164
+ };
165
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
166
+ const mw = buildRequirePermissionFromEngine(runtime, "sources", "read", engine);
167
+ const res = mkRes();
168
+ const req = {
169
+ session: { sub: "u", name: "u", roles: ["viewer"], tenant: "acme", iat: 0, exp: Date.now() / 1000 + 60 },
170
+ };
171
+ mw(req, res, () => undefined);
172
+ assert.equal(seenCtx?.tenant, "acme", "tenant from session must flow into engine.evaluate ctx");
173
+ });
174
+ test("buildRequirePermissionFromEngine — sessionless basic-mode request denies via engine (roles undefined → no permission)", () => {
175
+ const engine = new BuiltinPolicyEngine(DEFAULT_POLICY);
176
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
177
+ const mw = buildRequirePermissionFromEngine(runtime, "sources", "read", engine);
178
+ const res = mkRes();
179
+ let called = false;
180
+ mw(mkReq(), res, () => { called = true; });
181
+ assert.equal(called, false);
182
+ assert.equal(res.statusCode, 403);
183
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Stateless signed-cookie sessions for the management plane (`/api/*`).
3
+ *
4
+ * The cookie value is `<base64url(payload)>.<base64url(hmacSha256(payload))>`.
5
+ * The payload is a small JSON object with the user's identity, the issued-at
6
+ * timestamp and the absolute expiry. No server-side store; rotating the
7
+ * secret invalidates every outstanding session.
8
+ *
9
+ * MCP transport authentication still uses {@link Credential} bearer tokens
10
+ * — see `./credentials.ts`. This module is exclusively for the browser /
11
+ * UI / `/api/*` plane.
12
+ */
13
+ export interface SessionPayload {
14
+ /** Stable user identifier (username for the local-users store, sub claim for OIDC, ...) */
15
+ sub: string;
16
+ /** Display name shown in the UI. May equal `sub`. */
17
+ name: string;
18
+ /** Email address when the identity provider supplied one. Optional —
19
+ * local-users-mode sessions usually omit this; OIDC sessions populate
20
+ * from the `email` claim when present + verified by the IdP. */
21
+ email?: string;
22
+ /** Tenant the identity belongs to. Omitted when single-tenant — the
23
+ * request handler defaults to "default" via normaliseTenant() so
24
+ * existing single-tenant deployments need no migration. */
25
+ tenant?: string;
26
+ /** Optional list of role identifiers — used by later phases for RBAC. */
27
+ roles?: string[];
28
+ /** Issued-at, seconds since epoch. */
29
+ iat: number;
30
+ /** Hard expiry, seconds since epoch. */
31
+ exp: number;
32
+ }
33
+ export interface SessionConfig {
34
+ /** Symmetric key. Must be at least 32 bytes. */
35
+ secret: string;
36
+ /** Cookie lifetime, seconds. Defaults to 12 hours. */
37
+ ttlSeconds?: number;
38
+ /** Cookie name. Defaults to `omcp_session`. */
39
+ cookieName?: string;
40
+ }
41
+ export declare const DEFAULT_SESSION_TTL_SECONDS: number;
42
+ export declare const DEFAULT_COOKIE_NAME = "omcp_session";
43
+ /** Create a signed cookie value for the given identity. */
44
+ export declare function issueSession(identity: Pick<SessionPayload, "sub" | "name" | "roles" | "email" | "tenant">, cfg: SessionConfig, now?: number): {
45
+ cookie: string;
46
+ payload: SessionPayload;
47
+ };
48
+ /** Reject cookies above this size before any crypto work — practical
49
+ * browser cookies stay well under 4 KB and a runaway input shouldn't
50
+ * even reach the HMAC step. Defense-in-depth; Express's
51
+ * `maxHttpHeaderSize` (16 KB by default) is the outer bound. */
52
+ export declare const MAX_COOKIE_BYTES = 4096;
53
+ /** Verify a cookie value. Returns the payload on success, null on any failure. */
54
+ export declare function verifySession(cookieValue: string | undefined | null, cfg: SessionConfig, now?: number): SessionPayload | null;
55
+ /** Render a Set-Cookie header value for an issued session. */
56
+ export declare function setCookieHeader(cookie: string, cfg: SessionConfig, opts?: {
57
+ secure?: boolean;
58
+ }): string;
59
+ /** Render a Set-Cookie header that immediately expires the session cookie. */
60
+ export declare function clearCookieHeader(cfg: SessionConfig, opts?: {
61
+ secure?: boolean;
62
+ }): string;
63
+ /** Parse the named cookie from a raw Cookie header. */
64
+ export declare function readCookie(cookieHeader: string | undefined | null, name?: string): string | null;
65
+ /** Generate a cryptographically strong fallback secret. Logged-once recommended. */
66
+ export declare function generateSecret(): string;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Stateless signed-cookie sessions for the management plane (`/api/*`).
3
+ *
4
+ * The cookie value is `<base64url(payload)>.<base64url(hmacSha256(payload))>`.
5
+ * The payload is a small JSON object with the user's identity, the issued-at
6
+ * timestamp and the absolute expiry. No server-side store; rotating the
7
+ * secret invalidates every outstanding session.
8
+ *
9
+ * MCP transport authentication still uses {@link Credential} bearer tokens
10
+ * — see `./credentials.ts`. This module is exclusively for the browser /
11
+ * UI / `/api/*` plane.
12
+ */
13
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
14
+ export const DEFAULT_SESSION_TTL_SECONDS = 12 * 60 * 60;
15
+ export const DEFAULT_COOKIE_NAME = "omcp_session";
16
+ function b64urlEncode(buf) {
17
+ return buf.toString("base64url");
18
+ }
19
+ function b64urlDecode(s) {
20
+ try {
21
+ return Buffer.from(s, "base64url");
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function sign(secret, payload) {
28
+ return createHmac("sha256", secret).update(payload).digest("base64url");
29
+ }
30
+ /** Create a signed cookie value for the given identity. */
31
+ export function issueSession(identity, cfg, now = Math.floor(Date.now() / 1000)) {
32
+ assertSecret(cfg.secret);
33
+ const ttl = cfg.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
34
+ const payload = {
35
+ sub: identity.sub,
36
+ name: identity.name,
37
+ email: identity.email,
38
+ tenant: identity.tenant,
39
+ roles: identity.roles,
40
+ iat: now,
41
+ exp: now + ttl,
42
+ };
43
+ const payloadStr = b64urlEncode(Buffer.from(JSON.stringify(payload)));
44
+ const sig = sign(cfg.secret, payloadStr);
45
+ return { cookie: `${payloadStr}.${sig}`, payload };
46
+ }
47
+ /** Reject cookies above this size before any crypto work — practical
48
+ * browser cookies stay well under 4 KB and a runaway input shouldn't
49
+ * even reach the HMAC step. Defense-in-depth; Express's
50
+ * `maxHttpHeaderSize` (16 KB by default) is the outer bound. */
51
+ export const MAX_COOKIE_BYTES = 4096;
52
+ /** Verify a cookie value. Returns the payload on success, null on any failure. */
53
+ export function verifySession(cookieValue, cfg, now = Math.floor(Date.now() / 1000)) {
54
+ if (!cookieValue)
55
+ return null;
56
+ if (cookieValue.length > MAX_COOKIE_BYTES)
57
+ return null;
58
+ assertSecret(cfg.secret);
59
+ const dot = cookieValue.indexOf(".");
60
+ if (dot <= 0 || dot === cookieValue.length - 1)
61
+ return null;
62
+ const payloadStr = cookieValue.slice(0, dot);
63
+ const sig = cookieValue.slice(dot + 1);
64
+ const expected = sign(cfg.secret, payloadStr);
65
+ // Constant-time compare on equal-length buffers; reject length mismatch first.
66
+ const a = Buffer.from(sig);
67
+ const b = Buffer.from(expected);
68
+ if (a.length !== b.length || !timingSafeEqual(a, b))
69
+ return null;
70
+ const raw = b64urlDecode(payloadStr);
71
+ if (!raw)
72
+ return null;
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw.toString("utf8"));
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ if (!isSessionPayload(parsed))
81
+ return null;
82
+ if (parsed.exp <= now)
83
+ return null;
84
+ return parsed;
85
+ }
86
+ function isSessionPayload(v) {
87
+ if (!v || typeof v !== "object")
88
+ return false;
89
+ const o = v;
90
+ if (typeof o.sub !== "string" || typeof o.name !== "string")
91
+ return false;
92
+ if (typeof o.iat !== "number" || typeof o.exp !== "number")
93
+ return false;
94
+ if (o.roles !== undefined && !(Array.isArray(o.roles) && o.roles.every((r) => typeof r === "string")))
95
+ return false;
96
+ if (o.email !== undefined && typeof o.email !== "string")
97
+ return false;
98
+ if (o.tenant !== undefined && typeof o.tenant !== "string")
99
+ return false;
100
+ return true;
101
+ }
102
+ function assertSecret(secret) {
103
+ if (!secret || secret.length < 32) {
104
+ throw new Error("session secret must be at least 32 characters");
105
+ }
106
+ }
107
+ /** Render a Set-Cookie header value for an issued session. */
108
+ export function setCookieHeader(cookie, cfg, opts = {}) {
109
+ const ttl = cfg.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
110
+ const name = cfg.cookieName ?? DEFAULT_COOKIE_NAME;
111
+ const parts = [
112
+ `${name}=${cookie}`,
113
+ `Max-Age=${ttl}`,
114
+ "Path=/",
115
+ "HttpOnly",
116
+ "SameSite=Lax",
117
+ ];
118
+ if (opts.secure !== false)
119
+ parts.push("Secure");
120
+ return parts.join("; ");
121
+ }
122
+ /** Render a Set-Cookie header that immediately expires the session cookie. */
123
+ export function clearCookieHeader(cfg, opts = {}) {
124
+ const name = cfg.cookieName ?? DEFAULT_COOKIE_NAME;
125
+ const parts = [`${name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
126
+ if (opts.secure !== false)
127
+ parts.push("Secure");
128
+ return parts.join("; ");
129
+ }
130
+ /** Parse the named cookie from a raw Cookie header. */
131
+ export function readCookie(cookieHeader, name = DEFAULT_COOKIE_NAME) {
132
+ if (!cookieHeader)
133
+ return null;
134
+ for (const part of cookieHeader.split(/;\s*/)) {
135
+ const eq = part.indexOf("=");
136
+ if (eq <= 0)
137
+ continue;
138
+ if (part.slice(0, eq) === name)
139
+ return part.slice(eq + 1);
140
+ }
141
+ return null;
142
+ }
143
+ /** Generate a cryptographically strong fallback secret. Logged-once recommended. */
144
+ export function generateSecret() {
145
+ return randomBytes(48).toString("base64url");
146
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { issueSession, verifySession, setCookieHeader, clearCookieHeader, readCookie, generateSecret, DEFAULT_COOKIE_NAME, } from "./session.js";
4
+ const secret = "a".repeat(48);
5
+ test("issueSession + verifySession — round-trips identity", () => {
6
+ const now = 1_700_000_000;
7
+ const { cookie, payload } = issueSession({ sub: "alice", name: "Alice", roles: ["operator"] }, { secret }, now);
8
+ assert.equal(payload.sub, "alice");
9
+ assert.equal(payload.exp, now + 12 * 60 * 60);
10
+ const verified = verifySession(cookie, { secret }, now + 1);
11
+ assert.ok(verified, "expected verified payload");
12
+ assert.equal(verified.sub, "alice");
13
+ assert.deepEqual(verified.roles, ["operator"]);
14
+ });
15
+ test("issueSession + verifySession — round-trips email when present", () => {
16
+ const now = 1_700_000_000;
17
+ const { cookie } = issueSession({ sub: "alice", name: "Alice", email: "alice@example.test", roles: ["operator"] }, { secret }, now);
18
+ const verified = verifySession(cookie, { secret }, now + 1);
19
+ assert.ok(verified);
20
+ assert.equal(verified.email, "alice@example.test");
21
+ });
22
+ test("issueSession + verifySession — omits email when caller doesn't supply one", () => {
23
+ const now = 1_700_000_000;
24
+ const { cookie } = issueSession({ sub: "alice", name: "Alice", roles: ["operator"] }, { secret }, now);
25
+ const verified = verifySession(cookie, { secret }, now + 1);
26
+ assert.ok(verified);
27
+ assert.equal(verified.email, undefined);
28
+ });
29
+ test("verifySession — rejects an expired cookie", () => {
30
+ const now = 1_700_000_000;
31
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret, ttlSeconds: 60 }, now);
32
+ assert.equal(verifySession(cookie, { secret }, now + 61), null);
33
+ });
34
+ test("verifySession — rejects tampered payload", () => {
35
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
36
+ const [payload, sig] = cookie.split(".");
37
+ const flipped = Buffer.from(payload, "base64url").toString("utf8").replace("alice", "mallory");
38
+ const evil = Buffer.from(flipped).toString("base64url") + "." + sig;
39
+ assert.equal(verifySession(evil, { secret }), null);
40
+ });
41
+ test("verifySession — rejects cookie signed with a different secret", () => {
42
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
43
+ assert.equal(verifySession(cookie, { secret: "b".repeat(48) }), null);
44
+ });
45
+ test("verifySession — null / empty / malformed cookies return null", () => {
46
+ assert.equal(verifySession(null, { secret }), null);
47
+ assert.equal(verifySession("", { secret }), null);
48
+ assert.equal(verifySession("no-dot-anywhere", { secret }), null);
49
+ assert.equal(verifySession(".trailing", { secret }), null);
50
+ assert.equal(verifySession("leading.", { secret }), null);
51
+ });
52
+ test("verifySession — rejects oversized cookies before any crypto work", () => {
53
+ const huge = "x".repeat(10_000) + "." + "y".repeat(10);
54
+ assert.equal(verifySession(huge, { secret }), null);
55
+ });
56
+ test("verifySession — rejects short secret at verify time too (fail-closed)", () => {
57
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
58
+ assert.throws(() => verifySession(cookie, { secret: "short" }));
59
+ });
60
+ test("issueSession — rejects secrets shorter than 32 chars", () => {
61
+ assert.throws(() => issueSession({ sub: "alice", name: "A" }, { secret: "short" }));
62
+ });
63
+ test("setCookieHeader / clearCookieHeader — render expected attributes", () => {
64
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
65
+ const setHdr = setCookieHeader(cookie, { secret });
66
+ assert.match(setHdr, /^omcp_session=/);
67
+ assert.match(setHdr, /HttpOnly/);
68
+ assert.match(setHdr, /SameSite=Lax/);
69
+ assert.match(setHdr, /Secure/);
70
+ assert.match(setHdr, /Max-Age=43200/);
71
+ const clearHdr = clearCookieHeader({ secret });
72
+ assert.match(clearHdr, /^omcp_session=;/);
73
+ assert.match(clearHdr, /Max-Age=0/);
74
+ });
75
+ test("setCookieHeader — `secure: false` omits Secure (dev / plain http)", () => {
76
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
77
+ const hdr = setCookieHeader(cookie, { secret }, { secure: false });
78
+ assert.doesNotMatch(hdr, /Secure/);
79
+ });
80
+ test("readCookie — extracts the named cookie from a Cookie header", () => {
81
+ assert.equal(readCookie("foo=bar; omcp_session=hello; baz=qux"), "hello");
82
+ assert.equal(readCookie("omcp_session=hello", DEFAULT_COOKIE_NAME), "hello");
83
+ assert.equal(readCookie(undefined), null);
84
+ assert.equal(readCookie("missing=1"), null);
85
+ });
86
+ test("generateSecret — always returns ≥ 32 chars of url-safe entropy", () => {
87
+ const s = generateSecret();
88
+ assert.ok(s.length >= 32);
89
+ assert.match(s, /^[A-Za-z0-9_-]+$/);
90
+ });