@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,140 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { evaluateBatch, batchResultToCsv, DEFAULT_BATCH_LIMITS, } from "./batch-dry-run.js";
4
+ class FakeEngine {
5
+ // Allow when the roles array contains "admin", or
6
+ // when ((resource, action) is (sources, read) for any role).
7
+ evaluate(roles, resource, action) {
8
+ if (roles?.includes("admin"))
9
+ return { allowed: true, reason: "admin role" };
10
+ if (resource === "sources" && action === "read")
11
+ return { allowed: true, reason: "public read" };
12
+ return { allowed: false, reason: `denied: roles=${(roles ?? []).join(",")} can't ${action} on ${resource}` };
13
+ }
14
+ list() {
15
+ return []; // not exercised by these tests
16
+ }
17
+ roles() {
18
+ return ["admin", "viewer"];
19
+ }
20
+ kind() {
21
+ return "fake";
22
+ }
23
+ }
24
+ const VALID_RES = new Set(["sources", "services", "settings"]);
25
+ const VALID_ACT = new Set(["read", "write", "delete"]);
26
+ function req(overrides = {}) {
27
+ return {
28
+ subjects: [{ key: "alice", roles: ["viewer"] }],
29
+ resources: ["sources"],
30
+ actions: ["read"],
31
+ ...overrides,
32
+ };
33
+ }
34
+ test("evaluateBatch: empty request → empty matrix + zero totals", async () => {
35
+ const r = await evaluateBatch(new FakeEngine(), { subjects: [], resources: [], actions: [] }, VALID_RES, VALID_ACT);
36
+ assert.deepEqual(r.matrix, {});
37
+ assert.deepEqual(r.totals, { cells: 0, allow: 0, deny: 0 });
38
+ assert.deepEqual(r.dropped, []);
39
+ });
40
+ test("evaluateBatch: 1×1×1 returns one verdict cell", async () => {
41
+ const r = await evaluateBatch(new FakeEngine(), req(), VALID_RES, VALID_ACT);
42
+ assert.equal(r.matrix.alice.sources.read.allowed, true);
43
+ assert.equal(r.matrix.alice.sources.read.reason, "public read");
44
+ assert.equal(r.totals.cells, 1);
45
+ assert.equal(r.totals.allow, 1);
46
+ assert.equal(r.totals.deny, 0);
47
+ });
48
+ test("evaluateBatch: full 2×2×2 matrix populated end-to-end", async () => {
49
+ const r = await evaluateBatch(new FakeEngine(), {
50
+ subjects: [
51
+ { key: "alice", roles: ["viewer"] },
52
+ { key: "bob", roles: ["admin"] },
53
+ ],
54
+ resources: ["sources", "services"],
55
+ actions: ["read", "delete"],
56
+ }, VALID_RES, VALID_ACT);
57
+ assert.equal(r.totals.cells, 8);
58
+ assert.equal(r.matrix.alice.sources.read.allowed, true); // public read
59
+ assert.equal(r.matrix.alice.services.read.allowed, false); // viewer can't read services
60
+ assert.equal(r.matrix.bob.services.delete.allowed, true); // admin
61
+ });
62
+ test("evaluateBatch: unknown resource → dropped + matrix omits it", async () => {
63
+ const r = await evaluateBatch(new FakeEngine(), req({ resources: ["sources", "totally-bogus"] }), VALID_RES, VALID_ACT);
64
+ assert.equal(r.dropped.length, 1);
65
+ assert.equal(r.dropped[0].kind, "resource");
66
+ assert.equal(r.dropped[0].value, "totally-bogus");
67
+ // Matrix has only the surviving resource
68
+ assert.deepEqual(Object.keys(r.matrix.alice), ["sources"]);
69
+ });
70
+ test("evaluateBatch: unknown action → dropped", async () => {
71
+ const r = await evaluateBatch(new FakeEngine(), req({ actions: ["read", "blow-up"] }), VALID_RES, VALID_ACT);
72
+ assert.equal(r.dropped.some((d) => d.kind === "action" && d.value === "blow-up"), true);
73
+ });
74
+ test("evaluateBatch: deduplicates repeated inputs", async () => {
75
+ const r = await evaluateBatch(new FakeEngine(), {
76
+ subjects: [
77
+ { key: "alice", roles: ["viewer"] },
78
+ { key: "alice", roles: ["admin"] }, // dropped because key already seen
79
+ ],
80
+ resources: ["sources", "sources", "services"],
81
+ actions: ["read", "read", "delete"],
82
+ }, VALID_RES, VALID_ACT);
83
+ // alice runs once, with the first-seen roles array (viewer); 1 subject × 2 resources × 2 actions = 4 cells.
84
+ assert.equal(Object.keys(r.matrix).length, 1);
85
+ assert.equal(r.totals.cells, 4);
86
+ });
87
+ test("evaluateBatch: malformed subject (missing roles) dropped with note", async () => {
88
+ const r = await evaluateBatch(new FakeEngine(), {
89
+ subjects: [
90
+ { key: "alice", roles: ["viewer"] },
91
+ { key: "broken" },
92
+ ],
93
+ resources: ["sources"],
94
+ actions: ["read"],
95
+ }, VALID_RES, VALID_ACT);
96
+ assert.equal(Object.keys(r.matrix).length, 1);
97
+ assert.ok(r.dropped.some((d) => d.kind === "subject" && d.value === "broken"));
98
+ });
99
+ test("evaluateBatch: cap enforcement truncates oversize lists, notes in dropped", async () => {
100
+ const subjects = Array.from({ length: 5 }, (_, i) => ({ key: `s${i}`, roles: ["viewer"] }));
101
+ const resources = Array.from({ length: 3 }, (_, i) => `sources`); // dedup → 1
102
+ const r = await evaluateBatch(new FakeEngine(), { subjects, resources, actions: ["read"] }, VALID_RES, VALID_ACT, { maxSubjects: 2, maxResources: 5, maxActions: 5 });
103
+ // truncated to 2 subjects × 1 resource × 1 action
104
+ assert.equal(r.totals.cells, 2);
105
+ assert.ok(r.dropped.some((d) => d.kind === "cap" && d.value.startsWith("subjects=")));
106
+ });
107
+ test("evaluateBatch: per-subject tenant is threaded into engine.evaluate", async () => {
108
+ let lastTenant;
109
+ class TenantTracker {
110
+ evaluate(_roles, _r, _a, ctx) {
111
+ lastTenant = ctx?.tenant;
112
+ return { allowed: true };
113
+ }
114
+ list() { return []; }
115
+ roles() { return []; }
116
+ kind() { return "tracker"; }
117
+ }
118
+ await evaluateBatch(new TenantTracker(), {
119
+ subjects: [{ key: "alice", roles: ["viewer"], tenant: "acme" }],
120
+ resources: ["sources"],
121
+ actions: ["read"],
122
+ }, VALID_RES, VALID_ACT);
123
+ assert.equal(lastTenant, "acme");
124
+ });
125
+ test("batchResultToCsv: produces the documented header + escapes commas and quotes", async () => {
126
+ const r = await evaluateBatch(new FakeEngine(), {
127
+ subjects: [{ key: 'alice,senior "lead"', roles: ["viewer"] }],
128
+ resources: ["sources"],
129
+ actions: ["read"],
130
+ }, VALID_RES, VALID_ACT);
131
+ const csv = batchResultToCsv(r);
132
+ assert.match(csv.split("\n")[0], /^subject,resource,action,allowed,reason$/);
133
+ // Quoted because of comma + embedded quotes doubled
134
+ assert.match(csv, /"alice,senior ""lead"""/);
135
+ });
136
+ test("DEFAULT_BATCH_LIMITS matches the documented 100×100×10 cap", () => {
137
+ assert.equal(DEFAULT_BATCH_LIMITS.maxSubjects, 100);
138
+ assert.equal(DEFAULT_BATCH_LIMITS.maxResources, 100);
139
+ assert.equal(DEFAULT_BATCH_LIMITS.maxActions, 10);
140
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Policy-engine abstraction.
3
+ *
4
+ * Today the management-plane RBAC checks call `hasPermission()` /
5
+ * `listGrantedPermissions()` which read the built-in DEFAULT_POLICY
6
+ * map. That's fine for the single-deployment case, but the plan
7
+ * (E5) wires in:
8
+ *
9
+ * - File-loaded custom policies (slice 2, this module)
10
+ * - External OPA via HTTP eval (slice 4)
11
+ *
12
+ * Both surfaces share the same shape: given (role, resource, action),
13
+ * answer allowed / not allowed; and given a role, enumerate every
14
+ * granted (resource, action) pair for UI display.
15
+ *
16
+ * This interface is deliberately narrow so a future Rego engine, a
17
+ * remote OPA call, or any operator-supplied evaluator drops in
18
+ * without touching the call sites.
19
+ */
20
+ import type { Permission, Resource, Action } from "../rbac.js";
21
+ export interface EvalResult {
22
+ allowed: boolean;
23
+ /** Optional human-readable explanation (for /api/policy?dry-run). */
24
+ reason?: string;
25
+ }
26
+ /** Optional context the gate can pass when it has more identity
27
+ * info than just the role set — e.g. the active tenant. Engines
28
+ * that consult external policy (OPA) thread this into the Rego
29
+ * input so tenant-conditional rules can fire. Built-in engines
30
+ * ignore it. Adding fields here is additive: future-engine code
31
+ * reads what it needs, callers populate what they have. */
32
+ export interface EvalContext {
33
+ tenant?: string;
34
+ }
35
+ export interface PolicyEngine {
36
+ /** One-shot evaluation: does this role-set grant the permission? */
37
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action, ctx?: EvalContext): EvalResult;
38
+ /** Enumerate every (resource, action) the role-set grants. */
39
+ list(roles: string[] | undefined, ctx?: EvalContext): Permission[];
40
+ /** Surface the active role catalogue (for UI tabs / docs). */
41
+ roles(): string[];
42
+ /** Short identifier for logging / /api/info — "builtin", "file:…",
43
+ * "opa:…". */
44
+ kind(): string;
45
+ }
46
+ /** Built-in engine — wraps a plain {role: Permission[]} map. */
47
+ export declare class BuiltinPolicyEngine implements PolicyEngine {
48
+ private readonly policy;
49
+ private readonly origin;
50
+ constructor(policy: Record<string, Permission[]>, origin?: string);
51
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action, _ctx?: EvalContext): EvalResult;
52
+ list(roles: string[] | undefined, _ctx?: EvalContext): Permission[];
53
+ roles(): string[];
54
+ kind(): string;
55
+ /** Expose the underlying policy for /api/policy reflection. */
56
+ raw(): Record<string, Permission[]>;
57
+ /** Hot-swap the policy in place. Existing gate middleware closed
58
+ * over THIS engine instance will see the new map on the next
59
+ * evaluate() call — no restart required. The `readonly` modifier
60
+ * on `policy` only forbids reassignment of the field (TS); the
61
+ * underlying object reference stays the same, so we clear-and-
62
+ * refill instead of replacing it. */
63
+ replace(policy: Record<string, Permission[]>): void;
64
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Policy-engine abstraction.
3
+ *
4
+ * Today the management-plane RBAC checks call `hasPermission()` /
5
+ * `listGrantedPermissions()` which read the built-in DEFAULT_POLICY
6
+ * map. That's fine for the single-deployment case, but the plan
7
+ * (E5) wires in:
8
+ *
9
+ * - File-loaded custom policies (slice 2, this module)
10
+ * - External OPA via HTTP eval (slice 4)
11
+ *
12
+ * Both surfaces share the same shape: given (role, resource, action),
13
+ * answer allowed / not allowed; and given a role, enumerate every
14
+ * granted (resource, action) pair for UI display.
15
+ *
16
+ * This interface is deliberately narrow so a future Rego engine, a
17
+ * remote OPA call, or any operator-supplied evaluator drops in
18
+ * without touching the call sites.
19
+ */
20
+ /** Built-in engine — wraps a plain {role: Permission[]} map. */
21
+ export class BuiltinPolicyEngine {
22
+ policy;
23
+ origin;
24
+ constructor(policy, origin = "builtin") {
25
+ this.policy = policy;
26
+ this.origin = origin;
27
+ }
28
+ evaluate(roles, resource, action, _ctx) {
29
+ void _ctx; // builtin engine has no tenant-conditional rules
30
+ if (!roles || roles.length === 0) {
31
+ return { allowed: false, reason: "no roles on principal" };
32
+ }
33
+ for (const r of roles) {
34
+ const grants = this.policy[r];
35
+ if (!grants)
36
+ continue;
37
+ for (const g of grants) {
38
+ if (g.resource === resource && g.action === action) {
39
+ return { allowed: true, reason: `granted by role ${r}` };
40
+ }
41
+ }
42
+ }
43
+ return { allowed: false, reason: `roles [${roles.join(",")}] do not grant ${resource}:${action}` };
44
+ }
45
+ list(roles, _ctx) {
46
+ void _ctx;
47
+ if (!roles || roles.length === 0)
48
+ return [];
49
+ const seen = new Set();
50
+ const out = [];
51
+ for (const r of roles) {
52
+ const grants = this.policy[r];
53
+ if (!grants)
54
+ continue;
55
+ for (const g of grants) {
56
+ const key = g.resource + ":" + g.action;
57
+ if (seen.has(key))
58
+ continue;
59
+ seen.add(key);
60
+ out.push(g);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ roles() {
66
+ return Object.keys(this.policy);
67
+ }
68
+ kind() {
69
+ return this.origin;
70
+ }
71
+ /** Expose the underlying policy for /api/policy reflection. */
72
+ raw() {
73
+ return this.policy;
74
+ }
75
+ /** Hot-swap the policy in place. Existing gate middleware closed
76
+ * over THIS engine instance will see the new map on the next
77
+ * evaluate() call — no restart required. The `readonly` modifier
78
+ * on `policy` only forbids reassignment of the field (TS); the
79
+ * underlying object reference stays the same, so we clear-and-
80
+ * refill instead of replacing it. */
81
+ replace(policy) {
82
+ for (const k of Object.keys(this.policy))
83
+ delete this.policy[k];
84
+ for (const [k, v] of Object.entries(policy))
85
+ this.policy[k] = v;
86
+ }
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { BuiltinPolicyEngine } from "./engine.js";
4
+ import { DEFAULT_POLICY } from "../rbac.js";
5
+ import { loadPolicyFromString, PolicyLoadError } from "./loader.js";
6
+ test("BuiltinPolicyEngine — evaluate returns allowed for granted perm", () => {
7
+ const e = new BuiltinPolicyEngine(DEFAULT_POLICY);
8
+ const r = e.evaluate(["viewer"], "sources", "read");
9
+ assert.equal(r.allowed, true);
10
+ assert.match(r.reason, /granted by role viewer/);
11
+ });
12
+ test("BuiltinPolicyEngine — evaluate returns denied with role context", () => {
13
+ const e = new BuiltinPolicyEngine(DEFAULT_POLICY);
14
+ const r = e.evaluate(["viewer"], "sources", "write");
15
+ assert.equal(r.allowed, false);
16
+ assert.match(r.reason, /viewer.*do not grant sources:write/);
17
+ });
18
+ test("BuiltinPolicyEngine — evaluate denies when roles missing / empty", () => {
19
+ const e = new BuiltinPolicyEngine(DEFAULT_POLICY);
20
+ assert.equal(e.evaluate(undefined, "sources", "read").allowed, false);
21
+ assert.equal(e.evaluate([], "sources", "read").allowed, false);
22
+ });
23
+ test("BuiltinPolicyEngine — list dedupes across overlapping roles", () => {
24
+ const e = new BuiltinPolicyEngine(DEFAULT_POLICY);
25
+ const both = e.list(["viewer", "operator"]);
26
+ // operator inherits viewer's reads; the union shouldn't contain dupes
27
+ const keys = new Set(both.map((p) => p.resource + ":" + p.action));
28
+ assert.equal(keys.size, both.length);
29
+ });
30
+ test("BuiltinPolicyEngine.roles / kind", () => {
31
+ const e = new BuiltinPolicyEngine(DEFAULT_POLICY);
32
+ assert.deepEqual(e.roles().sort(), ["admin", "operator", "viewer"]);
33
+ assert.equal(e.kind(), "builtin");
34
+ });
35
+ test("loadPolicyFromString — happy path YAML", () => {
36
+ const yamlText = `
37
+ roles:
38
+ viewer:
39
+ - { resource: sources, action: read }
40
+ - { resource: services, action: read }
41
+ custom-bot:
42
+ - { resource: redaction, action: bypass }
43
+ `;
44
+ const e = loadPolicyFromString(yamlText, "test");
45
+ assert.equal(e.kind(), "test");
46
+ assert.equal(e.evaluate(["viewer"], "sources", "read").allowed, true);
47
+ assert.equal(e.evaluate(["custom-bot"], "redaction", "bypass").allowed, true);
48
+ assert.equal(e.evaluate(["viewer"], "redaction", "bypass").allowed, false);
49
+ });
50
+ test("loadPolicyFromString — rejects unknown resource", () => {
51
+ const yamlText = `
52
+ roles:
53
+ viewer:
54
+ - { resource: sourcez, action: read }
55
+ `;
56
+ assert.throws(() => loadPolicyFromString(yamlText, "t"), /resource 'sourcez' unknown/);
57
+ });
58
+ test("loadPolicyFromString — rejects unknown action", () => {
59
+ const yamlText = `
60
+ roles:
61
+ viewer:
62
+ - { resource: sources, action: peek }
63
+ `;
64
+ assert.throws(() => loadPolicyFromString(yamlText, "t"), /action 'peek' unknown/);
65
+ });
66
+ test("loadPolicyFromString — rejects unexpected key (typo guard)", () => {
67
+ const yamlText = `
68
+ roles:
69
+ viewer:
70
+ - { tesource: sources, action: read }
71
+ `;
72
+ assert.throws(() => loadPolicyFromString(yamlText, "t"), /unexpected key 'tesource'/);
73
+ });
74
+ test("loadPolicyFromString — rejects non-object root / missing roles", () => {
75
+ assert.throws(() => loadPolicyFromString("[1,2,3]", "t"), /expected an object/);
76
+ assert.throws(() => loadPolicyFromString("foo: bar", "t"), /missing or non-object 'roles'/);
77
+ });
78
+ test("loadPolicyFromString — rejects role with non-array grants", () => {
79
+ assert.throws(() => loadPolicyFromString("roles:\n viewer: 'read-everything'", "t"), /viewer must be a list/);
80
+ });
81
+ test("loadPolicyFromString — surfaces YAML parse errors with origin", () => {
82
+ // Tab character is invalid YAML indentation.
83
+ assert.throws(() => loadPolicyFromString("\troles:\n\tviewer: []", "my-test"), PolicyLoadError);
84
+ });
85
+ test("loadPolicyFromString — file-supplied admin REPLACES built-in admin (no merge)", () => {
86
+ // The default admin role gets redaction:bypass. A custom admin that
87
+ // omits it must not silently inherit; otherwise an operator's
88
+ // attempt to lock down the role would be defeated.
89
+ const text = `
90
+ roles:
91
+ admin:
92
+ - { resource: sources, action: read }
93
+ `;
94
+ const e = loadPolicyFromString(text, "t");
95
+ assert.equal(e.evaluate(["admin"], "sources", "read").allowed, true);
96
+ assert.equal(e.evaluate(["admin"], "redaction", "bypass").allowed, false, "custom admin must NOT inherit redaction:bypass");
97
+ assert.equal(e.evaluate(["admin"], "users", "delete").allowed, false, "custom admin must NOT inherit users:delete");
98
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Load a policy from a YAML or JSON file and turn it into a
3
+ * BuiltinPolicyEngine. Validation enforces every entry has a
4
+ * known resource + action shape; unknown fields are rejected so a
5
+ * typo in operator-facing config fails fast and loud rather than
6
+ * silently dropping grants.
7
+ *
8
+ * File shape:
9
+ * roles:
10
+ * viewer:
11
+ * - { resource: sources, action: read }
12
+ * - { resource: services, action: read }
13
+ * operator:
14
+ * - { resource: sources, action: write }
15
+ * - { resource: settings, action: write }
16
+ * admin:
17
+ * - { resource: redaction, action: bypass }
18
+ * # etc.
19
+ *
20
+ * The loader does NOT inherit-merge built-in roles — a file-supplied
21
+ * `admin` REPLACES the built-in `admin`. Inheritance / patching is
22
+ * an operator-side concern (anchor / merge in YAML, jq filters, etc.).
23
+ */
24
+ import type { Permission, Resource, Action } from "../rbac.js";
25
+ import { type PolicyEngine } from "./engine.js";
26
+ export declare const VALID_RESOURCES: ReadonlySet<Resource>;
27
+ export declare const VALID_ACTIONS: ReadonlySet<Action>;
28
+ export declare class PolicyLoadError extends Error {
29
+ constructor(msg: string);
30
+ }
31
+ /** Parse a YAML/JSON string into a validated policy + return an engine. */
32
+ export declare function loadPolicyFromString(text: string, origin: string): PolicyEngine;
33
+ /** Read a file (utf-8) and load it as a policy. Lets operators
34
+ * surface the on-disk path in error messages. */
35
+ export declare function loadPolicyFromFile(path: string): PolicyEngine;
36
+ /** Render a policy map into the YAML/JSON shape the loader reads.
37
+ * Pure helper — separated from the file-write step so a future
38
+ * PolicyEngine implementation that doesn't speak the file format
39
+ * can compose differently. */
40
+ export declare function serializePolicy(policy: Record<string, Permission[]>): string;
41
+ /** Atomic write of the policy file. Same tmp+rename pattern used by
42
+ * products + users — a crash mid-write leaves the previous file
43
+ * intact. mode 0o600 so the on-disk RBAC catalogue isn't
44
+ * world-readable on multi-tenant hosts. */
45
+ export declare function writePolicyFile(path: string, policy: Record<string, Permission[]>): Promise<void>;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Load a policy from a YAML or JSON file and turn it into a
3
+ * BuiltinPolicyEngine. Validation enforces every entry has a
4
+ * known resource + action shape; unknown fields are rejected so a
5
+ * typo in operator-facing config fails fast and loud rather than
6
+ * silently dropping grants.
7
+ *
8
+ * File shape:
9
+ * roles:
10
+ * viewer:
11
+ * - { resource: sources, action: read }
12
+ * - { resource: services, action: read }
13
+ * operator:
14
+ * - { resource: sources, action: write }
15
+ * - { resource: settings, action: write }
16
+ * admin:
17
+ * - { resource: redaction, action: bypass }
18
+ * # etc.
19
+ *
20
+ * The loader does NOT inherit-merge built-in roles — a file-supplied
21
+ * `admin` REPLACES the built-in `admin`. Inheritance / patching is
22
+ * an operator-side concern (anchor / merge in YAML, jq filters, etc.).
23
+ */
24
+ import { readFileSync } from "node:fs";
25
+ import yaml from "js-yaml";
26
+ import { BuiltinPolicyEngine } from "./engine.js";
27
+ export const VALID_RESOURCES = new Set([
28
+ "sources", "services", "health", "topology", "settings",
29
+ "connectors", "audit", "catalog", "users", "redaction",
30
+ "products",
31
+ ]);
32
+ export const VALID_ACTIONS = new Set(["read", "write", "delete", "bypass"]);
33
+ export class PolicyLoadError extends Error {
34
+ constructor(msg) {
35
+ super(msg);
36
+ this.name = "PolicyLoadError";
37
+ }
38
+ }
39
+ /** Parse a YAML/JSON string into a validated policy + return an engine. */
40
+ export function loadPolicyFromString(text, origin) {
41
+ let parsed;
42
+ try {
43
+ parsed = yaml.load(text);
44
+ }
45
+ catch (e) {
46
+ throw new PolicyLoadError(`failed to parse policy ${origin}: ${e.message}`);
47
+ }
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
49
+ throw new PolicyLoadError(`${origin}: expected an object with a 'roles' map`);
50
+ }
51
+ const roles = parsed.roles;
52
+ if (!roles || typeof roles !== "object" || Array.isArray(roles)) {
53
+ throw new PolicyLoadError(`${origin}: missing or non-object 'roles' field`);
54
+ }
55
+ const policy = {};
56
+ for (const [role, grants] of Object.entries(roles)) {
57
+ if (!Array.isArray(grants)) {
58
+ throw new PolicyLoadError(`${origin}: roles.${role} must be a list of {resource, action} entries`);
59
+ }
60
+ const perms = [];
61
+ for (let i = 0; i < grants.length; i++) {
62
+ const g = grants[i];
63
+ if (!g || typeof g !== "object" || Array.isArray(g)) {
64
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}] must be an object`);
65
+ }
66
+ // Reject unexpected keys FIRST so a typo like `tesource:` gets
67
+ // the helpful "unexpected key 'tesource'" message instead of
68
+ // the misleading "resource 'undefined' unknown" that the value
69
+ // check below would otherwise emit (no `resource` field
70
+ // present in the object).
71
+ for (const k of Object.keys(g)) {
72
+ if (k !== "resource" && k !== "action") {
73
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}] has unexpected key '${k}'`);
74
+ }
75
+ }
76
+ const resource = g.resource;
77
+ const action = g.action;
78
+ if (typeof resource !== "string" || !VALID_RESOURCES.has(resource)) {
79
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}].resource '${String(resource)}' unknown (allowed: ${[...VALID_RESOURCES].join(", ")})`);
80
+ }
81
+ if (typeof action !== "string" || !VALID_ACTIONS.has(action)) {
82
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}].action '${String(action)}' unknown (allowed: ${[...VALID_ACTIONS].join(", ")})`);
83
+ }
84
+ perms.push({ resource: resource, action: action });
85
+ }
86
+ policy[role] = perms;
87
+ }
88
+ return new BuiltinPolicyEngine(policy, origin);
89
+ }
90
+ /** Read a file (utf-8) and load it as a policy. Lets operators
91
+ * surface the on-disk path in error messages. */
92
+ export function loadPolicyFromFile(path) {
93
+ let text;
94
+ try {
95
+ text = readFileSync(path, "utf8");
96
+ }
97
+ catch (e) {
98
+ throw new PolicyLoadError(`failed to read policy ${path}: ${e.message}`);
99
+ }
100
+ return loadPolicyFromString(text, `file:${path}`);
101
+ }
102
+ /** Render a policy map into the YAML/JSON shape the loader reads.
103
+ * Pure helper — separated from the file-write step so a future
104
+ * PolicyEngine implementation that doesn't speak the file format
105
+ * can compose differently. */
106
+ export function serializePolicy(policy) {
107
+ // Lock in the field order so a round-trip-through-this-function
108
+ // is stable diffs in a version-controlled file. Roles sorted
109
+ // alphabetically; grants sorted by (resource, action) inside
110
+ // each role.
111
+ const rolesOut = {};
112
+ for (const role of Object.keys(policy).sort()) {
113
+ const grants = policy[role] || [];
114
+ const sorted = grants
115
+ .slice()
116
+ .sort((a, b) => (a.resource + ":" + a.action).localeCompare(b.resource + ":" + b.action))
117
+ .map((g) => ({ resource: g.resource, action: g.action }));
118
+ rolesOut[role] = sorted;
119
+ }
120
+ return yaml.dump({ roles: rolesOut }, { sortKeys: false, lineWidth: 100 });
121
+ }
122
+ /** Atomic write of the policy file. Same tmp+rename pattern used by
123
+ * products + users — a crash mid-write leaves the previous file
124
+ * intact. mode 0o600 so the on-disk RBAC catalogue isn't
125
+ * world-readable on multi-tenant hosts. */
126
+ export async function writePolicyFile(path, policy) {
127
+ // Validate via the parse path before writing — a bad input
128
+ // shape would otherwise produce a file the boot loader then
129
+ // rejects (fail-closed reboot). Validate-then-write keeps the
130
+ // good-policy invariant.
131
+ loadPolicyFromString(serializePolicy(policy), "(in-memory)");
132
+ const { writeFile, rename } = await import("node:fs/promises");
133
+ const text = serializePolicy(policy);
134
+ const tmp = path + ".tmp";
135
+ await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
136
+ await rename(tmp, path);
137
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writePolicyFile, loadPolicyFromFile, loadPolicyFromString, serializePolicy, VALID_RESOURCES } from "./loader.js";
4
+ import { BuiltinPolicyEngine } from "./engine.js";
5
+ test("VALID_RESOURCES — includes products (closes a pre-existing inconsistency vs rbac.ts)", () => {
6
+ assert.ok(VALID_RESOURCES.has("products"), "products must be a recognised resource for file-loaded policies");
7
+ });
8
+ test("serializePolicy — round-trips through the parser cleanly", () => {
9
+ const text = serializePolicy({
10
+ admin: [
11
+ { resource: "sources", action: "delete" },
12
+ { resource: "users", action: "delete" },
13
+ ],
14
+ viewer: [{ resource: "sources", action: "read" }],
15
+ });
16
+ // Parsing the serialised text via loadPolicyFromString must yield
17
+ // an engine with the same role grants — round-trip is stable.
18
+ const e = loadPolicyFromString(text, "test");
19
+ assert.deepEqual(e.roles().sort(), ["admin", "viewer"]);
20
+ const admin = e.list(["admin"]).map((p) => p.resource + ":" + p.action).sort();
21
+ assert.deepEqual(admin, ["sources:delete", "users:delete"]);
22
+ });
23
+ test("serializePolicy — deterministic ordering (roles + grants both sorted)", () => {
24
+ const a = serializePolicy({
25
+ z: [{ resource: "sources", action: "write" }, { resource: "audit", action: "read" }],
26
+ a: [{ resource: "users", action: "read" }],
27
+ });
28
+ const b = serializePolicy({
29
+ a: [{ resource: "users", action: "read" }],
30
+ z: [{ resource: "audit", action: "read" }, { resource: "sources", action: "write" }],
31
+ });
32
+ // Same logical policy → byte-identical text. Important for git-diff sanity.
33
+ assert.equal(a, b);
34
+ });
35
+ test("writePolicyFile — atomic round-trip preserves shape", async () => {
36
+ const { mkdtemp, rm } = await import("node:fs/promises");
37
+ const { tmpdir } = await import("node:os");
38
+ const { join } = await import("node:path");
39
+ const dir = await mkdtemp(join(tmpdir(), "omcp-policy-"));
40
+ try {
41
+ const path = join(dir, "policy.yaml");
42
+ await writePolicyFile(path, {
43
+ admin: [{ resource: "sources", action: "delete" }],
44
+ operator: [{ resource: "sources", action: "write" }],
45
+ });
46
+ const engine = loadPolicyFromFile(path);
47
+ assert.deepEqual(engine.roles().sort(), ["admin", "operator"]);
48
+ }
49
+ finally {
50
+ await rm(dir, { recursive: true, force: true });
51
+ }
52
+ });
53
+ test("writePolicyFile — rejects an invalid resource before writing the file", async () => {
54
+ const { mkdtemp, rm, readdir } = await import("node:fs/promises");
55
+ const { tmpdir } = await import("node:os");
56
+ const { join } = await import("node:path");
57
+ const dir = await mkdtemp(join(tmpdir(), "omcp-policy-reject-"));
58
+ try {
59
+ const path = join(dir, "policy.yaml");
60
+ await assert.rejects(writePolicyFile(path, {
61
+ admin: [{ resource: "nope", action: "read" }],
62
+ }), /unknown/i);
63
+ // No file (or tmp) was created — validate-then-write held.
64
+ const entries = await readdir(dir);
65
+ assert.deepEqual(entries, []);
66
+ }
67
+ finally {
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ test("BuiltinPolicyEngine.replace — mutates the inner map in place (gate closures see the new policy)", () => {
72
+ const engine = new BuiltinPolicyEngine({
73
+ admin: [{ resource: "sources", action: "delete" }],
74
+ });
75
+ // Capture a reference to the raw map BEFORE replace — verify the
76
+ // reference is preserved (hot-swap, not reassign).
77
+ const before = engine.raw();
78
+ engine.replace({
79
+ admin: [{ resource: "sources", action: "delete" }, { resource: "audit", action: "read" }],
80
+ viewer: [{ resource: "sources", action: "read" }],
81
+ });
82
+ assert.equal(before, engine.raw(), "raw() must return the same object reference after replace()");
83
+ assert.deepEqual(engine.roles().sort(), ["admin", "viewer"]);
84
+ assert.equal(engine.evaluate(["admin"], "audit", "read").allowed, true);
85
+ assert.equal(engine.evaluate(["viewer"], "sources", "read").allowed, true);
86
+ });