@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.
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +129 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +1188 -120
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +10 -6
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +1729 -100
- 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) ??
|
|
73
|
-
rolesClaim: nonEmpty(env.OMCP_OIDC_ROLES_CLAIM) ??
|
|
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
|
}
|
|
@@ -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>;
|