@thotischner/observability-mcp 1.7.0 → 1.8.1

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