@thotischner/observability-mcp 1.8.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -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 +144 -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.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -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 +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- 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/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -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/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -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/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -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/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -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 +15 -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 +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- 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 +33 -11
- package/dist/tools/topology.test.js +45 -0
- 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/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -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 +2529 -145
- package/package.json +13 -3
|
@@ -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,144 @@
|
|
|
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
|
+
// Reject keys that could mutate Object.prototype if injected from
|
|
67
|
+
// user input — `__proto__`, `constructor`, `prototype`. CodeQL
|
|
68
|
+
// js/prototype-polluting-assignment + js/remote-property-injection.
|
|
69
|
+
const DANGEROUS = new Set(["__proto__", "constructor", "prototype"]);
|
|
70
|
+
const isSafeKey = (k) => !DANGEROUS.has(k);
|
|
71
|
+
// Plain object so JSON round-trip + deep-equal in tests keeps
|
|
72
|
+
// working; the DANGEROUS guard below is the actual protection.
|
|
73
|
+
const matrix = {};
|
|
74
|
+
let allowCount = 0;
|
|
75
|
+
let denyCount = 0;
|
|
76
|
+
for (const s of subjects) {
|
|
77
|
+
if (!isSafeKey(s.key)) {
|
|
78
|
+
dropped.push({ kind: "subject", value: s.key, reason: "reserved key name" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
matrix[s.key] = {};
|
|
82
|
+
for (const r of resources) {
|
|
83
|
+
if (!isSafeKey(String(r)))
|
|
84
|
+
continue;
|
|
85
|
+
matrix[s.key][r] = {};
|
|
86
|
+
for (const a of actions) {
|
|
87
|
+
if (!isSafeKey(String(a)))
|
|
88
|
+
continue;
|
|
89
|
+
const verdict = await Promise.resolve(engine.evaluate(s.roles, r, a, s.tenant ? { tenant: s.tenant } : undefined));
|
|
90
|
+
matrix[s.key][r][a] = {
|
|
91
|
+
allowed: verdict.allowed,
|
|
92
|
+
reason: verdict.reason,
|
|
93
|
+
};
|
|
94
|
+
if (verdict.allowed)
|
|
95
|
+
allowCount += 1;
|
|
96
|
+
else
|
|
97
|
+
denyCount += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
matrix,
|
|
103
|
+
dropped,
|
|
104
|
+
totals: {
|
|
105
|
+
cells: subjects.length * resources.length * actions.length,
|
|
106
|
+
allow: allowCount,
|
|
107
|
+
deny: denyCount,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function unique(xs) {
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const x of xs) {
|
|
115
|
+
if (seen.has(x))
|
|
116
|
+
continue;
|
|
117
|
+
seen.add(x);
|
|
118
|
+
out.push(x);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/** Turn a batch result into CSV — `subject,resource,action,allowed,reason`. */
|
|
123
|
+
export function batchResultToCsv(result) {
|
|
124
|
+
const lines = ["subject,resource,action,allowed,reason"];
|
|
125
|
+
for (const [subject, perResource] of Object.entries(result.matrix)) {
|
|
126
|
+
for (const [resource, perAction] of Object.entries(perResource)) {
|
|
127
|
+
for (const [action, verdict] of Object.entries(perAction)) {
|
|
128
|
+
lines.push([
|
|
129
|
+
csvEscape(subject),
|
|
130
|
+
csvEscape(resource),
|
|
131
|
+
csvEscape(action),
|
|
132
|
+
verdict.allowed ? "allow" : "deny",
|
|
133
|
+
csvEscape(verdict.reason ?? ""),
|
|
134
|
+
].join(","));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
|
140
|
+
function csvEscape(v) {
|
|
141
|
+
if (/[",\n\r]/.test(v))
|
|
142
|
+
return `"${v.replace(/"/g, '""')}"`;
|
|
143
|
+
return v;
|
|
144
|
+
}
|
|
@@ -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>;
|
|
@@ -27,6 +27,7 @@ import { BuiltinPolicyEngine } from "./engine.js";
|
|
|
27
27
|
export const VALID_RESOURCES = new Set([
|
|
28
28
|
"sources", "services", "health", "topology", "settings",
|
|
29
29
|
"connectors", "audit", "catalog", "users", "redaction",
|
|
30
|
+
"products",
|
|
30
31
|
]);
|
|
31
32
|
export const VALID_ACTIONS = new Set(["read", "write", "delete", "bypass"]);
|
|
32
33
|
export class PolicyLoadError extends Error {
|
|
@@ -98,3 +99,39 @@ export function loadPolicyFromFile(path) {
|
|
|
98
99
|
}
|
|
99
100
|
return loadPolicyFromString(text, `file:${path}`);
|
|
100
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
|
+
});
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* for auth/oidc/, and OPA traffic stays inside the cluster).
|
|
28
28
|
*/
|
|
29
29
|
import type { Permission, Resource, Action } from "../rbac.js";
|
|
30
|
-
import type { PolicyEngine, EvalResult } from "./engine.js";
|
|
30
|
+
import type { PolicyEngine, EvalResult, EvalContext } from "./engine.js";
|
|
31
31
|
export type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
|
32
32
|
export interface OpaConfig {
|
|
33
33
|
/** Base URL, e.g. http://opa:8181 (no trailing slash). */
|
|
@@ -57,13 +57,13 @@ export declare class OpaPolicyEngine implements PolicyEngine {
|
|
|
57
57
|
private cacheKey;
|
|
58
58
|
private now;
|
|
59
59
|
private query;
|
|
60
|
-
evaluate(roles: string[] | undefined, resource: Resource, action: Action): EvalResult;
|
|
60
|
+
evaluate(roles: string[] | undefined, resource: Resource, action: Action, ctx?: EvalContext): EvalResult;
|
|
61
61
|
/** Async warm of the evaluate cache. Public so a long-running
|
|
62
62
|
* caller can `await engine.warmEvaluate(...)` before the gate
|
|
63
63
|
* check if it cannot tolerate the warming-deny window. */
|
|
64
|
-
warmEvaluate(roles: string[], resource: string, action: string): Promise<EvalResult>;
|
|
65
|
-
list(roles: string[] | undefined): Permission[];
|
|
66
|
-
warmList(roles: string[]): Promise<Permission[]>;
|
|
64
|
+
warmEvaluate(roles: string[], resource: string, action: string, tenant?: string): Promise<EvalResult>;
|
|
65
|
+
list(roles: string[] | undefined, ctx?: EvalContext): Permission[];
|
|
66
|
+
warmList(roles: string[], tenant?: string): Promise<Permission[]>;
|
|
67
67
|
roles(): string[];
|
|
68
68
|
kind(): string;
|
|
69
69
|
}
|