@thotischner/observability-mcp 1.7.1 → 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 (105) 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/context.d.ts +13 -1
  76. package/dist/context.js +5 -1
  77. package/dist/index.js +1012 -29
  78. package/dist/net/egress-policy.js +2 -0
  79. package/dist/openapi.js +440 -0
  80. package/dist/openapi.test.d.ts +1 -0
  81. package/dist/openapi.test.js +64 -0
  82. package/dist/policy/redact.d.ts +44 -0
  83. package/dist/policy/redact.js +144 -0
  84. package/dist/policy/redact.test.d.ts +1 -0
  85. package/dist/policy/redact.test.js +172 -0
  86. package/dist/products/loader.d.ts +84 -0
  87. package/dist/products/loader.js +216 -0
  88. package/dist/products/loader.test.d.ts +1 -0
  89. package/dist/products/loader.test.js +168 -0
  90. package/dist/quota/limiter.d.ts +72 -0
  91. package/dist/quota/limiter.js +105 -0
  92. package/dist/quota/limiter.test.d.ts +1 -0
  93. package/dist/quota/limiter.test.js +119 -0
  94. package/dist/quota/token-budget.d.ts +119 -0
  95. package/dist/quota/token-budget.js +297 -0
  96. package/dist/quota/token-budget.test.d.ts +1 -0
  97. package/dist/quota/token-budget.test.js +215 -0
  98. package/dist/tenancy/context.d.ts +45 -0
  99. package/dist/tenancy/context.js +97 -0
  100. package/dist/tenancy/context.test.d.ts +1 -0
  101. package/dist/tenancy/context.test.js +72 -0
  102. package/dist/tenancy/migration.test.d.ts +7 -0
  103. package/dist/tenancy/migration.test.js +75 -0
  104. package/dist/ui/index.html +1454 -88
  105. package/package.json +20 -3
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Resolve the OIDC configuration from environment variables and turn
3
+ * it into the runtime shape the rest of the auth layer consumes.
4
+ *
5
+ * Mirrors the basic-mode resolution in src/index.ts: fail-closed on
6
+ * missing required config, allow an `OMCP_AUTH_ALLOW_FALLBACK=true`
7
+ * opt-out (handled by the caller — this module just signals via the
8
+ * `error` field).
9
+ *
10
+ * Required env (when OMCP_AUTH=oidc):
11
+ * OMCP_OIDC_ISSUER — IdP base URL (no trailing /.well-known/...)
12
+ * OMCP_OIDC_CLIENT_ID
13
+ * OMCP_OIDC_REDIRECT_URI — absolute, MUST match the registration
14
+ *
15
+ * Optional env:
16
+ * OMCP_OIDC_CLIENT_SECRET — confidential clients; public clients omit
17
+ * OMCP_OIDC_SCOPES — default "openid profile email"
18
+ * OMCP_OIDC_ROLES_CLAIM — dotted path; default "groups"
19
+ * OMCP_OIDC_ROLE_MAP — JSON {"<claim-value>": "<omcp-role>"};
20
+ * entries map directly to RBAC roles
21
+ * (viewer / operator / admin or custom).
22
+ * OMCP_OIDC_LOGOUT_REDIRECT — post-logout landing URL (default "/")
23
+ */
24
+ import { OidcClient } from "./client.js";
25
+ import { DEFAULT_TENANT, tenantFromClaim } from "../../tenancy/context.js";
26
+ /** Pure env-to-config translator. No I/O. */
27
+ export function resolveOidcConfig(env = process.env) {
28
+ const issuer = nonEmpty(env.OMCP_OIDC_ISSUER);
29
+ const clientId = nonEmpty(env.OMCP_OIDC_CLIENT_ID);
30
+ const redirectUri = nonEmpty(env.OMCP_OIDC_REDIRECT_URI);
31
+ const missing = [];
32
+ if (!issuer)
33
+ missing.push("OMCP_OIDC_ISSUER");
34
+ if (!clientId)
35
+ missing.push("OMCP_OIDC_CLIENT_ID");
36
+ if (!redirectUri)
37
+ missing.push("OMCP_OIDC_REDIRECT_URI");
38
+ if (missing.length > 0) {
39
+ return { error: `OMCP_AUTH=oidc requires ${missing.join(", ")}` };
40
+ }
41
+ if (!/^https?:\/\//i.test(issuer)) {
42
+ return { error: `OMCP_OIDC_ISSUER must be an absolute http(s):// URL, got ${issuer}` };
43
+ }
44
+ if (!/^https?:\/\//i.test(redirectUri)) {
45
+ return { error: `OMCP_OIDC_REDIRECT_URI must be an absolute http(s):// URL, got ${redirectUri}` };
46
+ }
47
+ let roleMap = {};
48
+ const roleMapRaw = nonEmpty(env.OMCP_OIDC_ROLE_MAP);
49
+ if (roleMapRaw) {
50
+ try {
51
+ const parsed = JSON.parse(roleMapRaw);
52
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ return { error: "OMCP_OIDC_ROLE_MAP must be a JSON object of {\"claim-value\":\"omcp-role\"}" };
54
+ }
55
+ for (const [k, v] of Object.entries(parsed)) {
56
+ if (typeof v !== "string") {
57
+ return { error: `OMCP_OIDC_ROLE_MAP[${k}] must be a string, got ${typeof v}` };
58
+ }
59
+ roleMap[k] = v;
60
+ }
61
+ }
62
+ catch (e) {
63
+ return { error: `OMCP_OIDC_ROLE_MAP is not valid JSON: ${e.message}` };
64
+ }
65
+ }
66
+ return {
67
+ config: {
68
+ issuer: issuer.replace(/\/$/, ""),
69
+ clientId: clientId,
70
+ clientSecret: nonEmpty(env.OMCP_OIDC_CLIENT_SECRET),
71
+ redirectUri: redirectUri,
72
+ scopes: nonEmpty(env.OMCP_OIDC_SCOPES) ?? "openid profile email",
73
+ rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ?? "groups",
74
+ roleMap,
75
+ logoutRedirect: nonEmpty(env.OMCP_OIDC_LOGOUT_REDIRECT) ?? "/",
76
+ tenantClaim: nonEmpty(env.OMCP_OIDC_TENANT_CLAIM) ?? "",
77
+ },
78
+ };
79
+ }
80
+ export function buildOidcRuntime(cfg, opts = {}) {
81
+ const client = opts.client ?? new OidcClient({
82
+ issuer: cfg.issuer,
83
+ clientId: cfg.clientId,
84
+ clientSecret: cfg.clientSecret,
85
+ redirectUri: cfg.redirectUri,
86
+ scopes: cfg.scopes,
87
+ });
88
+ return {
89
+ cfg,
90
+ client,
91
+ resolveRoles(claims) {
92
+ const raw = lookupClaim(claims, cfg.rolesClaim);
93
+ const values = Array.isArray(raw)
94
+ ? raw.filter((v) => typeof v === "string")
95
+ : typeof raw === "string"
96
+ ? [raw]
97
+ : [];
98
+ const roles = new Set();
99
+ for (const v of values) {
100
+ const mapped = cfg.roleMap[v];
101
+ if (mapped)
102
+ roles.add(mapped);
103
+ }
104
+ return [...roles];
105
+ },
106
+ resolveTenant(claims) {
107
+ return cfg.tenantClaim ? tenantFromClaim(claims, cfg.tenantClaim) : DEFAULT_TENANT;
108
+ },
109
+ };
110
+ }
111
+ function nonEmpty(v) {
112
+ if (v === undefined)
113
+ return undefined;
114
+ const t = v.trim();
115
+ return t.length > 0 ? t : undefined;
116
+ }
117
+ function lookupClaim(claims, dottedPath) {
118
+ const parts = dottedPath.split(".");
119
+ let cur = claims;
120
+ for (const p of parts) {
121
+ if (cur && typeof cur === "object" && !Array.isArray(cur) && p in cur) {
122
+ cur = cur[p];
123
+ }
124
+ else {
125
+ return undefined;
126
+ }
127
+ }
128
+ return cur;
129
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,180 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveOidcConfig, buildOidcRuntime } from "./runtime.js";
4
+ function envOf(overrides) {
5
+ return { ...overrides };
6
+ }
7
+ test("resolveOidcConfig — happy path with required vars only", () => {
8
+ const r = resolveOidcConfig(envOf({
9
+ OMCP_OIDC_ISSUER: "https://idp.test",
10
+ OMCP_OIDC_CLIENT_ID: "c-1",
11
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
12
+ }));
13
+ assert.equal(r.error, undefined);
14
+ assert.deepEqual(r.config, {
15
+ issuer: "https://idp.test",
16
+ clientId: "c-1",
17
+ clientSecret: undefined,
18
+ redirectUri: "https://app.test/cb",
19
+ scopes: "openid profile email",
20
+ rolesClaim: "groups",
21
+ roleMap: {},
22
+ logoutRedirect: "/",
23
+ tenantClaim: "",
24
+ });
25
+ });
26
+ test("resolveOidcConfig — honours OMCP_OIDC_TENANT_CLAIM", () => {
27
+ const r = resolveOidcConfig(envOf({
28
+ OMCP_OIDC_ISSUER: "https://idp.test",
29
+ OMCP_OIDC_CLIENT_ID: "c-1",
30
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
31
+ OMCP_OIDC_TENANT_CLAIM: "app.tenant_id",
32
+ }));
33
+ assert.equal(r.config?.tenantClaim, "app.tenant_id");
34
+ });
35
+ test("buildOidcRuntime.resolveTenant — empty claim path → default", () => {
36
+ const r = resolveOidcConfig(envOf({
37
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
38
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
39
+ }));
40
+ const rt = buildOidcRuntime(r.config);
41
+ assert.equal(rt.resolveTenant({ tenant: "acme" }), "default");
42
+ });
43
+ test("buildOidcRuntime.resolveTenant — dotted path", () => {
44
+ const r = resolveOidcConfig(envOf({
45
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
46
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
47
+ OMCP_OIDC_TENANT_CLAIM: "app.tenant_id",
48
+ }));
49
+ const rt = buildOidcRuntime(r.config);
50
+ assert.equal(rt.resolveTenant({ app: { tenant_id: "acme" } }), "acme");
51
+ assert.equal(rt.resolveTenant({ app: { other: "x" } }), "default");
52
+ });
53
+ test("resolveOidcConfig — surfaces ALL missing required vars in one message", () => {
54
+ const r = resolveOidcConfig(envOf({}));
55
+ assert.match(r.error ?? "", /OMCP_OIDC_ISSUER.*OMCP_OIDC_CLIENT_ID.*OMCP_OIDC_REDIRECT_URI/);
56
+ assert.equal(r.config, undefined);
57
+ });
58
+ test("resolveOidcConfig — rejects non-URL issuer / redirect", () => {
59
+ const r1 = resolveOidcConfig(envOf({
60
+ OMCP_OIDC_ISSUER: "idp.test",
61
+ OMCP_OIDC_CLIENT_ID: "c", OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
62
+ }));
63
+ assert.match(r1.error ?? "", /OMCP_OIDC_ISSUER must be an absolute/);
64
+ const r2 = resolveOidcConfig(envOf({
65
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
66
+ OMCP_OIDC_REDIRECT_URI: "/cb",
67
+ }));
68
+ assert.match(r2.error ?? "", /OMCP_OIDC_REDIRECT_URI must be an absolute/);
69
+ });
70
+ test("resolveOidcConfig — strips a single trailing slash off issuer", () => {
71
+ const r = resolveOidcConfig(envOf({
72
+ OMCP_OIDC_ISSUER: "https://idp.test/",
73
+ OMCP_OIDC_CLIENT_ID: "c", OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
74
+ }));
75
+ assert.equal(r.config?.issuer, "https://idp.test");
76
+ });
77
+ test("resolveOidcConfig — empty strings count as missing", () => {
78
+ const r = resolveOidcConfig(envOf({
79
+ OMCP_OIDC_ISSUER: " ",
80
+ OMCP_OIDC_CLIENT_ID: "",
81
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
82
+ }));
83
+ assert.match(r.error ?? "", /OMCP_OIDC_ISSUER.*OMCP_OIDC_CLIENT_ID/);
84
+ });
85
+ test("resolveOidcConfig — parses OMCP_OIDC_ROLE_MAP JSON", () => {
86
+ const r = resolveOidcConfig(envOf({
87
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
88
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
89
+ OMCP_OIDC_ROLE_MAP: '{"omcp-admin":"admin","omcp-ops":"operator","omcp-viewers":"viewer"}',
90
+ }));
91
+ assert.deepEqual(r.config?.roleMap, { "omcp-admin": "admin", "omcp-ops": "operator", "omcp-viewers": "viewer" });
92
+ });
93
+ test("resolveOidcConfig — rejects malformed OMCP_OIDC_ROLE_MAP", () => {
94
+ const bad = resolveOidcConfig(envOf({
95
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
96
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
97
+ OMCP_OIDC_ROLE_MAP: "not json",
98
+ }));
99
+ assert.match(bad.error ?? "", /not valid JSON/);
100
+ const wrongType = resolveOidcConfig(envOf({
101
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
102
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
103
+ OMCP_OIDC_ROLE_MAP: '["arr"]',
104
+ }));
105
+ assert.match(wrongType.error ?? "", /JSON object/);
106
+ const wrongValue = resolveOidcConfig(envOf({
107
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
108
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
109
+ OMCP_OIDC_ROLE_MAP: '{"x": 1}',
110
+ }));
111
+ assert.match(wrongValue.error ?? "", /must be a string/);
112
+ });
113
+ test("resolveOidcConfig — propagates OMCP_OIDC_CLIENT_SECRET (confidential client)", () => {
114
+ const r = resolveOidcConfig(envOf({
115
+ OMCP_OIDC_ISSUER: "https://idp.test",
116
+ OMCP_OIDC_CLIENT_ID: "c-1",
117
+ OMCP_OIDC_CLIENT_SECRET: "confidential-shared-secret",
118
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
119
+ }));
120
+ assert.equal(r.config?.clientSecret, "confidential-shared-secret");
121
+ });
122
+ test("resolveOidcConfig — honours OMCP_OIDC_SCOPES / ROLES_CLAIM / LOGOUT_REDIRECT", () => {
123
+ const r = resolveOidcConfig(envOf({
124
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
125
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
126
+ OMCP_OIDC_SCOPES: "openid email custom-scope",
127
+ OMCP_OIDC_ROLES_CLAIM: "realm_access.roles",
128
+ OMCP_OIDC_LOGOUT_REDIRECT: "https://app.test/bye",
129
+ }));
130
+ assert.equal(r.config?.scopes, "openid email custom-scope");
131
+ assert.equal(r.config?.rolesClaim, "realm_access.roles");
132
+ assert.equal(r.config?.logoutRedirect, "https://app.test/bye");
133
+ });
134
+ test("buildOidcRuntime.resolveRoles — flat groups claim with array value", () => {
135
+ const r = resolveOidcConfig(envOf({
136
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
137
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
138
+ OMCP_OIDC_ROLE_MAP: '{"omcp-admin":"admin","omcp-ops":"operator","other":"viewer"}',
139
+ }));
140
+ const rt = buildOidcRuntime(r.config);
141
+ assert.deepEqual(rt.resolveRoles({ groups: ["omcp-admin", "unknown", "omcp-ops"] }).sort(), ["admin", "operator"]);
142
+ });
143
+ test("buildOidcRuntime.resolveRoles — dotted claim path (Keycloak realm_access.roles shape)", () => {
144
+ const r = resolveOidcConfig(envOf({
145
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
146
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
147
+ OMCP_OIDC_ROLES_CLAIM: "realm_access.roles",
148
+ OMCP_OIDC_ROLE_MAP: '{"omcp-admin":"admin"}',
149
+ }));
150
+ const rt = buildOidcRuntime(r.config);
151
+ assert.deepEqual(rt.resolveRoles({ realm_access: { roles: ["omcp-admin"] } }), ["admin"]);
152
+ });
153
+ test("buildOidcRuntime.resolveRoles — scalar string claim value still maps", () => {
154
+ const r = resolveOidcConfig(envOf({
155
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
156
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
157
+ OMCP_OIDC_ROLES_CLAIM: "role",
158
+ OMCP_OIDC_ROLE_MAP: '{"sso-admin":"admin"}',
159
+ }));
160
+ const rt = buildOidcRuntime(r.config);
161
+ assert.deepEqual(rt.resolveRoles({ role: "sso-admin" }), ["admin"]);
162
+ });
163
+ test("buildOidcRuntime.resolveRoles — missing claim path yields empty roles (least privilege)", () => {
164
+ const r = resolveOidcConfig(envOf({
165
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
166
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
167
+ OMCP_OIDC_ROLE_MAP: '{"x":"admin"}',
168
+ }));
169
+ const rt = buildOidcRuntime(r.config);
170
+ assert.deepEqual(rt.resolveRoles({ sub: "alice" }), []);
171
+ });
172
+ test("buildOidcRuntime.resolveRoles — deduplicates when multiple claim values map to same role", () => {
173
+ const r = resolveOidcConfig(envOf({
174
+ OMCP_OIDC_ISSUER: "https://idp.test", OMCP_OIDC_CLIENT_ID: "c",
175
+ OMCP_OIDC_REDIRECT_URI: "https://app.test/cb",
176
+ OMCP_OIDC_ROLE_MAP: '{"a":"admin","b":"admin"}',
177
+ }));
178
+ const rt = buildOidcRuntime(r.config);
179
+ assert.deepEqual(rt.resolveRoles({ groups: ["a", "b"] }), ["admin"]);
180
+ });
@@ -0,0 +1,48 @@
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
+ export interface PolicyEngine {
27
+ /** One-shot evaluation: does this role-set grant the permission? */
28
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action): EvalResult;
29
+ /** Enumerate every (resource, action) the role-set grants. */
30
+ list(roles: string[] | undefined): Permission[];
31
+ /** Surface the active role catalogue (for UI tabs / docs). */
32
+ roles(): string[];
33
+ /** Short identifier for logging / /api/info — "builtin", "file:…",
34
+ * "opa:…". */
35
+ kind(): string;
36
+ }
37
+ /** Built-in engine — wraps a plain {role: Permission[]} map. */
38
+ export declare class BuiltinPolicyEngine implements PolicyEngine {
39
+ private readonly policy;
40
+ private readonly origin;
41
+ constructor(policy: Record<string, Permission[]>, origin?: string);
42
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action): EvalResult;
43
+ list(roles: string[] | undefined): Permission[];
44
+ roles(): string[];
45
+ kind(): string;
46
+ /** Expose the underlying policy for /api/policy reflection. */
47
+ raw(): Record<string, Permission[]>;
48
+ }
@@ -0,0 +1,73 @@
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) {
29
+ if (!roles || roles.length === 0) {
30
+ return { allowed: false, reason: "no roles on principal" };
31
+ }
32
+ for (const r of roles) {
33
+ const grants = this.policy[r];
34
+ if (!grants)
35
+ continue;
36
+ for (const g of grants) {
37
+ if (g.resource === resource && g.action === action) {
38
+ return { allowed: true, reason: `granted by role ${r}` };
39
+ }
40
+ }
41
+ }
42
+ return { allowed: false, reason: `roles [${roles.join(",")}] do not grant ${resource}:${action}` };
43
+ }
44
+ list(roles) {
45
+ if (!roles || roles.length === 0)
46
+ return [];
47
+ const seen = new Set();
48
+ const out = [];
49
+ for (const r of roles) {
50
+ const grants = this.policy[r];
51
+ if (!grants)
52
+ continue;
53
+ for (const g of grants) {
54
+ const key = g.resource + ":" + g.action;
55
+ if (seen.has(key))
56
+ continue;
57
+ seen.add(key);
58
+ out.push(g);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ roles() {
64
+ return Object.keys(this.policy);
65
+ }
66
+ kind() {
67
+ return this.origin;
68
+ }
69
+ /** Expose the underlying policy for /api/policy reflection. */
70
+ raw() {
71
+ return this.policy;
72
+ }
73
+ }
@@ -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,35 @@
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 { 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;
@@ -0,0 +1,100 @@
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
+ ]);
31
+ export const VALID_ACTIONS = new Set(["read", "write", "delete", "bypass"]);
32
+ export class PolicyLoadError extends Error {
33
+ constructor(msg) {
34
+ super(msg);
35
+ this.name = "PolicyLoadError";
36
+ }
37
+ }
38
+ /** Parse a YAML/JSON string into a validated policy + return an engine. */
39
+ export function loadPolicyFromString(text, origin) {
40
+ let parsed;
41
+ try {
42
+ parsed = yaml.load(text);
43
+ }
44
+ catch (e) {
45
+ throw new PolicyLoadError(`failed to parse policy ${origin}: ${e.message}`);
46
+ }
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
48
+ throw new PolicyLoadError(`${origin}: expected an object with a 'roles' map`);
49
+ }
50
+ const roles = parsed.roles;
51
+ if (!roles || typeof roles !== "object" || Array.isArray(roles)) {
52
+ throw new PolicyLoadError(`${origin}: missing or non-object 'roles' field`);
53
+ }
54
+ const policy = {};
55
+ for (const [role, grants] of Object.entries(roles)) {
56
+ if (!Array.isArray(grants)) {
57
+ throw new PolicyLoadError(`${origin}: roles.${role} must be a list of {resource, action} entries`);
58
+ }
59
+ const perms = [];
60
+ for (let i = 0; i < grants.length; i++) {
61
+ const g = grants[i];
62
+ if (!g || typeof g !== "object" || Array.isArray(g)) {
63
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}] must be an object`);
64
+ }
65
+ // Reject unexpected keys FIRST so a typo like `tesource:` gets
66
+ // the helpful "unexpected key 'tesource'" message instead of
67
+ // the misleading "resource 'undefined' unknown" that the value
68
+ // check below would otherwise emit (no `resource` field
69
+ // present in the object).
70
+ for (const k of Object.keys(g)) {
71
+ if (k !== "resource" && k !== "action") {
72
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}] has unexpected key '${k}'`);
73
+ }
74
+ }
75
+ const resource = g.resource;
76
+ const action = g.action;
77
+ if (typeof resource !== "string" || !VALID_RESOURCES.has(resource)) {
78
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}].resource '${String(resource)}' unknown (allowed: ${[...VALID_RESOURCES].join(", ")})`);
79
+ }
80
+ if (typeof action !== "string" || !VALID_ACTIONS.has(action)) {
81
+ throw new PolicyLoadError(`${origin}: roles.${role}[${i}].action '${String(action)}' unknown (allowed: ${[...VALID_ACTIONS].join(", ")})`);
82
+ }
83
+ perms.push({ resource: resource, action: action });
84
+ }
85
+ policy[role] = perms;
86
+ }
87
+ return new BuiltinPolicyEngine(policy, origin);
88
+ }
89
+ /** Read a file (utf-8) and load it as a policy. Lets operators
90
+ * surface the on-disk path in error messages. */
91
+ export function loadPolicyFromFile(path) {
92
+ let text;
93
+ try {
94
+ text = readFileSync(path, "utf8");
95
+ }
96
+ catch (e) {
97
+ throw new PolicyLoadError(`failed to read policy ${path}: ${e.message}`);
98
+ }
99
+ return loadPolicyFromString(text, `file:${path}`);
100
+ }