@thotischner/observability-mcp 1.8.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 (169) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -0,0 +1,22 @@
1
+ export interface VendorProfile {
2
+ /** Profile id, matches OMCP_OIDC_PROFILE. */
3
+ readonly name: string;
4
+ /** Human-readable label for logs + UI. */
5
+ readonly label: string;
6
+ /** Default OAuth scopes. */
7
+ readonly scopes: string;
8
+ /** Dotted claim path the IdP puts the user's group/role list under. */
9
+ readonly rolesClaim: string;
10
+ /** Default dotted claim path for tenant identification. Empty = all
11
+ * sessions land in the default tenant (operators usually leave
12
+ * this off unless they really do multi-tenant federation). */
13
+ readonly tenantClaim: string;
14
+ /** Doc URL deep-linked from the boot log on misconfiguration. */
15
+ readonly docs: string;
16
+ }
17
+ /** Returns the profile or undefined. Case-insensitive. */
18
+ export declare function getProfile(name: string | undefined): VendorProfile | undefined;
19
+ /** All known profile names, useful for help text + the boot log. */
20
+ export declare function profileNames(): string[];
21
+ /** Default profile = generic (matches pre-F6 behaviour exactly). */
22
+ export declare const DEFAULT_PROFILE: VendorProfile;
@@ -0,0 +1,95 @@
1
+ // SSO vendor presets.
2
+ //
3
+ // Each profile preconfigures the OIDC fields that differ between
4
+ // well-known providers (scopes, claim paths for roles/groups, default
5
+ // logout URL pattern). The operator still provides issuer / clientId
6
+ // / redirectUri / clientSecret via env; the profile fills in the rest
7
+ // so a typical Entra or Okta rollout doesn't need a custom config.
8
+ //
9
+ // Explicit env vars ALWAYS override profile defaults — profiles are
10
+ // best-effort defaults, never a hard override.
11
+ const PROFILES = {
12
+ // Generic OIDC — the existing behaviour (matches Keycloak, Authentik,
13
+ // Auth0, and any compliant provider that uses standard claims).
14
+ generic: {
15
+ name: "generic",
16
+ label: "Generic OIDC",
17
+ scopes: "openid profile email",
18
+ rolesClaim: "groups",
19
+ tenantClaim: "",
20
+ docs: "docs/auth-oidc.md",
21
+ },
22
+ // Keycloak ships groups under "groups" or "realm_access.roles"
23
+ // depending on mapper config. Default to "groups" to match the
24
+ // out-of-the-box realm export the demo profile uses.
25
+ keycloak: {
26
+ name: "keycloak",
27
+ label: "Keycloak",
28
+ scopes: "openid profile email",
29
+ rolesClaim: "groups",
30
+ tenantClaim: "",
31
+ // The existing OIDC reference covers Keycloak end-to-end (the
32
+ // demo profile ships a Keycloak realm export). A dedicated
33
+ // per-vendor page would duplicate it.
34
+ docs: "docs/auth-oidc.md",
35
+ },
36
+ // GitHub does not expose groups natively in its OIDC tokens; the
37
+ // common pattern is to use the "Teams" mapper or a custom claim
38
+ // provider. We default to "groups" so an operator who sets up a
39
+ // mapper sees their roles flow through; if they use a different
40
+ // claim, OMCP_OIDC_ROLES_CLAIM still overrides.
41
+ github: {
42
+ name: "github",
43
+ label: "GitHub",
44
+ scopes: "openid profile email read:org",
45
+ rolesClaim: "groups",
46
+ tenantClaim: "",
47
+ docs: "docs/auth-oidc-providers/github.md",
48
+ },
49
+ // Google Workspace exposes group membership via the "groups" claim
50
+ // when the directory API consent is granted; otherwise treat it as
51
+ // a single-user case (no group → no role mapping → user inherits
52
+ // the OIDC default role).
53
+ google: {
54
+ name: "google",
55
+ label: "Google Workspace",
56
+ scopes: "openid profile email",
57
+ rolesClaim: "groups",
58
+ tenantClaim: "hd", // "hd" = hosted domain, useful as a tenant key
59
+ docs: "docs/auth-oidc-providers/google.md",
60
+ },
61
+ // Microsoft Entra ID (formerly Azure AD) puts group IDs (object IDs)
62
+ // under "groups". For >200 groups it switches to a graph link
63
+ // claim — operators in that case must use a custom claim mapping
64
+ // policy; documented in the per-vendor doc.
65
+ "microsoft-entra": {
66
+ name: "microsoft-entra",
67
+ label: "Microsoft Entra ID",
68
+ scopes: "openid profile email",
69
+ rolesClaim: "groups",
70
+ tenantClaim: "tid", // "tid" = tenant id (Entra-native)
71
+ docs: "docs/auth-oidc-providers/microsoft-entra.md",
72
+ },
73
+ // Okta exposes groups via the "groups" claim when an OIDC Group
74
+ // claim mapper is added (default for any non-trivial app).
75
+ okta: {
76
+ name: "okta",
77
+ label: "Okta",
78
+ scopes: "openid profile email groups",
79
+ rolesClaim: "groups",
80
+ tenantClaim: "",
81
+ docs: "docs/auth-oidc-providers/okta.md",
82
+ },
83
+ };
84
+ /** Returns the profile or undefined. Case-insensitive. */
85
+ export function getProfile(name) {
86
+ if (!name)
87
+ return undefined;
88
+ return PROFILES[name.toLowerCase()];
89
+ }
90
+ /** All known profile names, useful for help text + the boot log. */
91
+ export function profileNames() {
92
+ return Object.keys(PROFILES);
93
+ }
94
+ /** Default profile = generic (matches pre-F6 behaviour exactly). */
95
+ export const DEFAULT_PROFILE = PROFILES.generic;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { getProfile, profileNames, DEFAULT_PROFILE, } from "./profiles.js";
4
+ test("profiles: getProfile returns known profiles, case-insensitive", () => {
5
+ assert.equal(getProfile("github")?.name, "github");
6
+ assert.equal(getProfile("Github")?.name, "github");
7
+ assert.equal(getProfile("MICROSOFT-ENTRA")?.name, "microsoft-entra");
8
+ });
9
+ test("profiles: getProfile returns undefined for unknown / empty", () => {
10
+ assert.equal(getProfile(undefined), undefined);
11
+ assert.equal(getProfile(""), undefined);
12
+ assert.equal(getProfile("nope"), undefined);
13
+ });
14
+ test("profiles: profileNames lists the 6 baked-in profiles", () => {
15
+ const names = profileNames();
16
+ for (const expected of [
17
+ "generic",
18
+ "keycloak",
19
+ "github",
20
+ "google",
21
+ "microsoft-entra",
22
+ "okta",
23
+ ]) {
24
+ assert.ok(names.includes(expected), `missing profile ${expected}`);
25
+ }
26
+ });
27
+ test("profiles: DEFAULT_PROFILE is generic and preserves the pre-F6 defaults", () => {
28
+ assert.equal(DEFAULT_PROFILE.name, "generic");
29
+ assert.equal(DEFAULT_PROFILE.scopes, "openid profile email");
30
+ assert.equal(DEFAULT_PROFILE.rolesClaim, "groups");
31
+ assert.equal(DEFAULT_PROFILE.tenantClaim, "");
32
+ });
33
+ test("profiles: vendor-specific tenant claims match the IdP-native key", () => {
34
+ // hd = hosted domain (Google) — useful as a tenant key for
35
+ // multi-org Workspace deployments
36
+ assert.equal(getProfile("google")?.tenantClaim, "hd");
37
+ // tid = tenant id (Entra-native)
38
+ assert.equal(getProfile("microsoft-entra")?.tenantClaim, "tid");
39
+ });
40
+ test("profiles: Okta scopes include 'groups' so the claim is actually returned", () => {
41
+ // Okta's group claim is only emitted when 'groups' is in the
42
+ // requested scope set; profile must include it as a default.
43
+ assert.match(getProfile("okta")?.scopes ?? "", /\bgroups\b/);
44
+ });
45
+ test("profiles: each profile has a docs path", () => {
46
+ for (const name of profileNames()) {
47
+ const p = getProfile(name);
48
+ assert.ok(p.docs, `profile ${name} has no docs path`);
49
+ assert.match(p.docs, /^docs\//, `profile ${name} docs should be a repo-relative path`);
50
+ }
51
+ });
@@ -34,6 +34,9 @@ export interface OidcRuntimeConfig {
34
34
  /** Dotted claim path to read the tenant from. Empty / missing → all
35
35
  * OIDC sessions land in the "default" tenant. */
36
36
  tenantClaim: string;
37
+ /** Vendor profile id (generic / github / google / microsoft-entra /
38
+ * okta / keycloak) — surfaced in /api/info for diagnostics. */
39
+ profile?: string;
37
40
  }
38
41
  export interface ResolveOidcResult {
39
42
  /** Fully validated runtime config; absent when `error` is set. */
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { OidcClient } from "./client.js";
25
25
  import { DEFAULT_TENANT, tenantFromClaim } from "../../tenancy/context.js";
26
+ import { getProfile, DEFAULT_PROFILE } from "./profiles.js";
26
27
  /** Pure env-to-config translator. No I/O. */
27
28
  export function resolveOidcConfig(env = process.env) {
28
29
  const issuer = nonEmpty(env.OMCP_OIDC_ISSUER);
@@ -63,17 +64,29 @@ export function resolveOidcConfig(env = process.env) {
63
64
  return { error: `OMCP_OIDC_ROLE_MAP is not valid JSON: ${e.message}` };
64
65
  }
65
66
  }
67
+ // Vendor profile resolves IdP-shaped defaults (scopes / rolesClaim /
68
+ // tenantClaim) when OMCP_OIDC_PROFILE is set. Explicit env vars
69
+ // always win — profiles only fill the gaps an operator chose not to
70
+ // set, so this is fully backwards-compatible with pre-F6 configs.
71
+ const profileName = nonEmpty(env.OMCP_OIDC_PROFILE);
72
+ const profile = (profileName && getProfile(profileName)) || DEFAULT_PROFILE;
73
+ if (profileName && !getProfile(profileName)) {
74
+ return {
75
+ error: `OMCP_OIDC_PROFILE=${profileName} is not a known profile (try one of: generic, keycloak, github, google, microsoft-entra, okta)`,
76
+ };
77
+ }
66
78
  return {
67
79
  config: {
68
80
  issuer: issuer.replace(/\/$/, ""),
69
81
  clientId: clientId,
70
82
  clientSecret: nonEmpty(env.OMCP_OIDC_CLIENT_SECRET),
71
83
  redirectUri: redirectUri,
72
- scopes: nonEmpty(env.OMCP_OIDC_SCOPES) ?? "openid profile email",
73
- rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ?? "groups",
84
+ scopes: nonEmpty(env.OMCP_OIDC_SCOPES) ?? profile.scopes,
85
+ rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ?? profile.rolesClaim,
74
86
  roleMap,
75
87
  logoutRedirect: nonEmpty(env.OMCP_OIDC_LOGOUT_REDIRECT) ?? "/",
76
- tenantClaim: nonEmpty(env.OMCP_OIDC_TENANT_CLAIM) ?? "",
88
+ tenantClaim: nonEmpty(env.OMCP_OIDC_TENANT_CLAIM) ?? profile.tenantClaim,
89
+ profile: profile.name,
77
90
  },
78
91
  };
79
92
  }
@@ -21,6 +21,7 @@ test("resolveOidcConfig — happy path with required vars only", () => {
21
21
  roleMap: {},
22
22
  logoutRedirect: "/",
23
23
  tenantClaim: "",
24
+ profile: "generic",
24
25
  });
25
26
  });
26
27
  test("resolveOidcConfig — honours OMCP_OIDC_TENANT_CLAIM", () => {
@@ -0,0 +1,56 @@
1
+ import type { PolicyEngine } from "./engine.js";
2
+ export interface BatchSubject {
3
+ /** Human-readable identifier echoed in the response (UI heat-map
4
+ * row label). Usually `<name>@<tenant>` or a group name. */
5
+ key: string;
6
+ /** Roles the subject would have at evaluation time. */
7
+ roles: string[];
8
+ /** Tenant the subject is acting under. Optional; defaults to the
9
+ * caller's session tenant at the handler level. */
10
+ tenant?: string;
11
+ }
12
+ export interface BatchDryRunRequest {
13
+ subjects: BatchSubject[];
14
+ /** Resources to probe — should match VALID_RESOURCES on the
15
+ * active engine. Unknown resources are dropped with a note. */
16
+ resources: string[];
17
+ /** Actions to probe — should match VALID_ACTIONS. */
18
+ actions: string[];
19
+ }
20
+ export interface BatchCellVerdict {
21
+ allowed: boolean;
22
+ reason?: string;
23
+ }
24
+ export interface BatchDryRunResult {
25
+ /** result[subjectKey][resource][action] = { allowed, reason } */
26
+ matrix: Record<string, Record<string, Record<string, BatchCellVerdict>>>;
27
+ /** Anything the handler skipped: bad resource, bad action, too
28
+ * many cells, etc. Helps the operator fix the next batch quickly. */
29
+ dropped: Array<{
30
+ kind: "resource" | "action" | "subject" | "cap";
31
+ value: string;
32
+ reason: string;
33
+ }>;
34
+ /** Summary counts to power the UI's headline stats. */
35
+ totals: {
36
+ cells: number;
37
+ allow: number;
38
+ deny: number;
39
+ };
40
+ }
41
+ export interface BatchLimits {
42
+ maxSubjects: number;
43
+ maxResources: number;
44
+ maxActions: number;
45
+ }
46
+ export declare const DEFAULT_BATCH_LIMITS: BatchLimits;
47
+ /**
48
+ * Run a batch dry-run against the policy engine. The engine is
49
+ * called once per cell — for the BuiltinPolicyEngine this is pure
50
+ * compute and cheap; for the OPA engine it's one Rego query per
51
+ * cell. The handler caps the matrix so a careless caller can't DoS
52
+ * an external OPA.
53
+ */
54
+ export declare function evaluateBatch(engine: PolicyEngine, req: BatchDryRunRequest, validResources: ReadonlySet<string>, validActions: ReadonlySet<string>, limits?: BatchLimits): Promise<BatchDryRunResult>;
55
+ /** Turn a batch result into CSV — `subject,resource,action,allowed,reason`. */
56
+ export declare function batchResultToCsv(result: BatchDryRunResult): string;
@@ -0,0 +1,129 @@
1
+ // Batch policy dry-run — Phase F16.
2
+ //
3
+ // The existing single-call dry-run probe (`GET /api/policy?roles=…
4
+ // &resource=…&action=…`) is great for "why did this one call fail"
5
+ // but doesn't scale to a security-review session reviewing a
6
+ // proposed role change. F16 adds a batch variant that evaluates
7
+ // every (subject × resource × action) combination in one pass and
8
+ // returns a matrix the UI can render as a heat-map.
9
+ //
10
+ // The handler stays out of the route file — pure compute is easier
11
+ // to unit-test and easier to reuse from a CI policy-diff job later.
12
+ export const DEFAULT_BATCH_LIMITS = {
13
+ maxSubjects: 100,
14
+ maxResources: 100,
15
+ maxActions: 10,
16
+ };
17
+ /**
18
+ * Run a batch dry-run against the policy engine. The engine is
19
+ * called once per cell — for the BuiltinPolicyEngine this is pure
20
+ * compute and cheap; for the OPA engine it's one Rego query per
21
+ * cell. The handler caps the matrix so a careless caller can't DoS
22
+ * an external OPA.
23
+ */
24
+ export async function evaluateBatch(engine, req, validResources, validActions, limits = DEFAULT_BATCH_LIMITS) {
25
+ const dropped = [];
26
+ // De-duplicate inputs; preserve first-seen order.
27
+ const seenSubjectKeys = new Set();
28
+ const subjects = [];
29
+ for (const s of req.subjects ?? []) {
30
+ if (!s || typeof s.key !== "string" || !Array.isArray(s.roles)) {
31
+ dropped.push({ kind: "subject", value: String(s?.key ?? "<malformed>"), reason: "missing key or roles[]" });
32
+ continue;
33
+ }
34
+ if (seenSubjectKeys.has(s.key))
35
+ continue;
36
+ seenSubjectKeys.add(s.key);
37
+ subjects.push(s);
38
+ }
39
+ const resources = unique(req.resources ?? []).filter((r) => {
40
+ if (!validResources.has(r)) {
41
+ dropped.push({ kind: "resource", value: r, reason: "not in active engine's VALID_RESOURCES" });
42
+ return false;
43
+ }
44
+ return true;
45
+ });
46
+ const actions = unique(req.actions ?? []).filter((a) => {
47
+ if (!validActions.has(a)) {
48
+ dropped.push({ kind: "action", value: a, reason: "not in active engine's VALID_ACTIONS" });
49
+ return false;
50
+ }
51
+ return true;
52
+ });
53
+ // Cap enforcement — favour clear-cap-error over partial silent results.
54
+ if (subjects.length > limits.maxSubjects) {
55
+ dropped.push({ kind: "cap", value: `subjects=${subjects.length}`, reason: `truncated to ${limits.maxSubjects} (cap)` });
56
+ subjects.length = limits.maxSubjects;
57
+ }
58
+ if (resources.length > limits.maxResources) {
59
+ dropped.push({ kind: "cap", value: `resources=${resources.length}`, reason: `truncated to ${limits.maxResources} (cap)` });
60
+ resources.length = limits.maxResources;
61
+ }
62
+ if (actions.length > limits.maxActions) {
63
+ dropped.push({ kind: "cap", value: `actions=${actions.length}`, reason: `truncated to ${limits.maxActions} (cap)` });
64
+ actions.length = limits.maxActions;
65
+ }
66
+ const matrix = {};
67
+ let allowCount = 0;
68
+ let denyCount = 0;
69
+ for (const s of subjects) {
70
+ matrix[s.key] = {};
71
+ for (const r of resources) {
72
+ matrix[s.key][r] = {};
73
+ for (const a of actions) {
74
+ const verdict = await Promise.resolve(engine.evaluate(s.roles, r, a, s.tenant ? { tenant: s.tenant } : undefined));
75
+ matrix[s.key][r][a] = {
76
+ allowed: verdict.allowed,
77
+ reason: verdict.reason,
78
+ };
79
+ if (verdict.allowed)
80
+ allowCount += 1;
81
+ else
82
+ denyCount += 1;
83
+ }
84
+ }
85
+ }
86
+ return {
87
+ matrix,
88
+ dropped,
89
+ totals: {
90
+ cells: subjects.length * resources.length * actions.length,
91
+ allow: allowCount,
92
+ deny: denyCount,
93
+ },
94
+ };
95
+ }
96
+ function unique(xs) {
97
+ const seen = new Set();
98
+ const out = [];
99
+ for (const x of xs) {
100
+ if (seen.has(x))
101
+ continue;
102
+ seen.add(x);
103
+ out.push(x);
104
+ }
105
+ return out;
106
+ }
107
+ /** Turn a batch result into CSV — `subject,resource,action,allowed,reason`. */
108
+ export function batchResultToCsv(result) {
109
+ const lines = ["subject,resource,action,allowed,reason"];
110
+ for (const [subject, perResource] of Object.entries(result.matrix)) {
111
+ for (const [resource, perAction] of Object.entries(perResource)) {
112
+ for (const [action, verdict] of Object.entries(perAction)) {
113
+ lines.push([
114
+ csvEscape(subject),
115
+ csvEscape(resource),
116
+ csvEscape(action),
117
+ verdict.allowed ? "allow" : "deny",
118
+ csvEscape(verdict.reason ?? ""),
119
+ ].join(","));
120
+ }
121
+ }
122
+ }
123
+ return lines.join("\n");
124
+ }
125
+ function csvEscape(v) {
126
+ if (/[",\n\r]/.test(v))
127
+ return `"${v.replace(/"/g, '""')}"`;
128
+ return v;
129
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
@@ -23,11 +23,20 @@ export interface EvalResult {
23
23
  /** Optional human-readable explanation (for /api/policy?dry-run). */
24
24
  reason?: string;
25
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
+ }
26
35
  export interface PolicyEngine {
27
36
  /** One-shot evaluation: does this role-set grant the permission? */
28
- evaluate(roles: string[] | undefined, resource: Resource, action: Action): EvalResult;
37
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action, ctx?: EvalContext): EvalResult;
29
38
  /** Enumerate every (resource, action) the role-set grants. */
30
- list(roles: string[] | undefined): Permission[];
39
+ list(roles: string[] | undefined, ctx?: EvalContext): Permission[];
31
40
  /** Surface the active role catalogue (for UI tabs / docs). */
32
41
  roles(): string[];
33
42
  /** Short identifier for logging / /api/info — "builtin", "file:…",
@@ -39,10 +48,17 @@ export declare class BuiltinPolicyEngine implements PolicyEngine {
39
48
  private readonly policy;
40
49
  private readonly origin;
41
50
  constructor(policy: Record<string, Permission[]>, origin?: string);
42
- evaluate(roles: string[] | undefined, resource: Resource, action: Action): EvalResult;
43
- list(roles: string[] | undefined): Permission[];
51
+ evaluate(roles: string[] | undefined, resource: Resource, action: Action, _ctx?: EvalContext): EvalResult;
52
+ list(roles: string[] | undefined, _ctx?: EvalContext): Permission[];
44
53
  roles(): string[];
45
54
  kind(): string;
46
55
  /** Expose the underlying policy for /api/policy reflection. */
47
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;
48
64
  }
@@ -25,7 +25,8 @@ export class BuiltinPolicyEngine {
25
25
  this.policy = policy;
26
26
  this.origin = origin;
27
27
  }
28
- evaluate(roles, resource, action) {
28
+ evaluate(roles, resource, action, _ctx) {
29
+ void _ctx; // builtin engine has no tenant-conditional rules
29
30
  if (!roles || roles.length === 0) {
30
31
  return { allowed: false, reason: "no roles on principal" };
31
32
  }
@@ -41,7 +42,8 @@ export class BuiltinPolicyEngine {
41
42
  }
42
43
  return { allowed: false, reason: `roles [${roles.join(",")}] do not grant ${resource}:${action}` };
43
44
  }
44
- list(roles) {
45
+ list(roles, _ctx) {
46
+ void _ctx;
45
47
  if (!roles || roles.length === 0)
46
48
  return [];
47
49
  const seen = new Set();
@@ -70,4 +72,16 @@ export class BuiltinPolicyEngine {
70
72
  raw() {
71
73
  return this.policy;
72
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
+ }
73
87
  }
@@ -21,7 +21,7 @@
21
21
  * `admin` REPLACES the built-in `admin`. Inheritance / patching is
22
22
  * an operator-side concern (anchor / merge in YAML, jq filters, etc.).
23
23
  */
24
- import type { Resource, Action } from "../rbac.js";
24
+ import type { Permission, Resource, Action } from "../rbac.js";
25
25
  import { type PolicyEngine } from "./engine.js";
26
26
  export declare const VALID_RESOURCES: ReadonlySet<Resource>;
27
27
  export declare const VALID_ACTIONS: ReadonlySet<Action>;
@@ -33,3 +33,13 @@ export declare function loadPolicyFromString(text: string, origin: string): Poli
33
33
  /** Read a file (utf-8) and load it as a policy. Lets operators
34
34
  * surface the on-disk path in error messages. */
35
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>;