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