@fabricorg/policy-opa 0.1.0 → 0.3.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/CHANGELOG.md +26 -0
- package/README.md +54 -15
- package/dist/index.cjs +217 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +176 -13
- package/dist/index.d.ts +176 -13
- package/dist/index.js +215 -18
- package/dist/index.js.map +1 -1
- package/package.json +71 -70
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @fabricorg/policy-opa
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-05-17
|
|
4
|
+
|
|
5
|
+
### Minor
|
|
6
|
+
|
|
7
|
+
- **Policy guidance support.** `OpaDecision` now accepts `guidance?: PolicyGuidance` and the evaluator maps it directly to `PolicyOutcome.guidance`. This makes OPA-backed policies render through the same audit/remediation UI contract as TypeScript code policies.
|
|
8
|
+
- **Engine metadata cleanup.** User-facing facts move out of `dispatchEvidence.engine.metadata` and into `PolicyOutcome.guidance`. Engine metadata remains focused on OPA provenance (`decisionPath`, `inputHash`, `decisionId`, `bundleRevision`, `conditionResults`, `obligations`, raw `provenance`).
|
|
9
|
+
- **Default-deny failure guidance.** OPA infrastructure failures still return `block` by default, now with generic corrective guidance telling operators to check OPA reachability, bundle serving, and decision shape.
|
|
10
|
+
- **Peer-deps:** `@fabricorg/platform ^0.4.0`.
|
|
11
|
+
|
|
12
|
+
## 0.2.0 — 2026-05-16
|
|
13
|
+
|
|
14
|
+
### Minor
|
|
15
|
+
|
|
16
|
+
- **Rich execution provenance.** Adds an `OpaPolicyExecutor` shape (`execute(path, input) → OpaPolicyExecution`) alongside the existing `OpaPolicyClient` (`evaluate(path, input)`). The factory detects either shape on `ctx.services[serviceKey]` and prefers `execute` when both are present. Lets adapter callers surface OPA's `decision_id`, `provenance.version`, and `provenance.bundles[*].revision` — fields the high-level `@open-policy-agent/opa` SDK discards.
|
|
17
|
+
- **`executorFromOpaHttp({ baseUrl, fetch?, headers?, provenance? })`** — official executor that POSTs to `{baseUrl}/v1/data/{path}?provenance=true`, parses the snake_case OPA response, and surfaces full provenance. Zero runtime deps; works on Node 20+ (uses `globalThis.fetch`), and on any runtime by passing a custom `fetch` implementation. Recommended for production deployments that need audit-grade evidence.
|
|
18
|
+
- **`executorFromOpaClient(client)`** — wraps the high-level SDK as an `OpaPolicyExecutor` for hosts that want to standardize on the executor interface even while accepting the limited provenance the SDK exposes.
|
|
19
|
+
- **`OpaPolicyBinding.bundleName?`** — optional selector for `provenance.bundles[name].revision`. Selection rules: explicit `bundleName` wins; otherwise a single-bundle response is used unambiguously; multiple bundles without a selector are recorded as `engine.metadata.bundleRevisionStatus: "ambiguous"` so audit dashboards can flag policies that need explicit selectors.
|
|
20
|
+
- **`engine.metadata` richer fields.** When the executor surfaces them, `dispatchEvidence.engine.metadata` now carries `decisionId`, `bundleName`, `bundleRevision`, `bundleRevisionStatus`, and the raw `provenance` block. `engine.version` is set from `provenance.version` when present, otherwise falls back to the `engineVersion` option.
|
|
21
|
+
- **`mapDecision` third argument.** `mapDecision: (decision, ctx, execution) => …` — the `execution` arg exposes `decisionId`, `bundleRevision`, `opaVersion`, and `provenance` so mappers can lift specific provenance fields into `outcome.metadata` if the host wants them queryable outside `dispatchEvidence`. Existing two-argument mappers from v0.1 continue to work (TypeScript permits implementations with fewer parameters).
|
|
22
|
+
- **Failure evidence now includes provenance** when available. A malformed-response failure under the executor path still records `decisionId` and `bundleRevision` in `engine.metadata` — useful for correlating "OPA returned something we couldn't parse" against OPA's own decision logs.
|
|
23
|
+
- **Tests:** 25 → 50. Added 25 covering: executor path with full provenance, single-bundle vs. multi-bundle vs. ambiguous selection, missing-provenance non-fatality, mapDecision third arg, service shape detection (executor preferred over client), bundle-selector unit tests, `executorFromOpaClient` wrapping, and `executorFromOpaHttp` URL construction / snake_case parsing / header forwarding / non-2xx handling.
|
|
24
|
+
|
|
25
|
+
### Backwards compatibility
|
|
26
|
+
|
|
27
|
+
All v0.1 surface area is preserved. Hosts wiring `ctx.services.opa` as an `OpaPolicyClient` (with `evaluate`) continue to work without code changes; they simply don't get OPA-side provenance in `engine.metadata`. Upgrade path is: swap the client for `executorFromOpaHttp(...)` to gain `decisionId` and `bundleRevision`, and add `bundleName` to bindings that target a specific OPA bundle.
|
|
28
|
+
|
|
3
29
|
## 0.1.0 — 2026-05-16
|
|
4
30
|
|
|
5
31
|
### Initial release
|
package/README.md
CHANGED
|
@@ -8,11 +8,16 @@ The platform stays zero-dependency and vendor-neutral; the OPA-specific glue liv
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pnpm add @fabricorg/platform @fabricorg/policy-opa
|
|
11
|
-
#
|
|
11
|
+
# Optionally the official SDK if you want the high-level client shape:
|
|
12
12
|
pnpm add @open-policy-agent/opa
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`@fabricorg/policy-opa` has **zero runtime dependencies**.
|
|
15
|
+
`@fabricorg/policy-opa` has **zero runtime dependencies**. Two wiring shapes are supported:
|
|
16
|
+
|
|
17
|
+
- **`OpaPolicyExecutor`** (recommended) — `execute(path, input) → OpaPolicyExecution`. Surfaces full OPA-side provenance (`decisionId`, `bundleRevision`, `opaVersion`, raw `provenance` block) under `engine.metadata`. The package ships `executorFromOpaHttp({ baseUrl, ... })` for this.
|
|
18
|
+
- **`OpaPolicyClient`** — `evaluate(path, input) → Promise<unknown>`. Structurally compatible with the high-level `@open-policy-agent/opa` SDK. Simpler to wire but the SDK discards `decisionId` and `provenance` from the response, so audit evidence is thinner.
|
|
19
|
+
|
|
20
|
+
If a service implements both methods, `execute` is preferred.
|
|
16
21
|
|
|
17
22
|
## Usage — the four pieces
|
|
18
23
|
|
|
@@ -20,7 +25,7 @@ A complete OPA-backed evaluator has four pieces, three of which the vertical own
|
|
|
20
25
|
|
|
21
26
|
### 1. The vertical policy manifest
|
|
22
27
|
|
|
23
|
-
Keep Fabric policy IDs, versions, decision paths,
|
|
28
|
+
Keep Fabric policy IDs, versions, decision paths, Rego package names, and bundle selectors in one place. Don't inline strings at call sites.
|
|
24
29
|
|
|
25
30
|
```ts
|
|
26
31
|
import type { OpaPolicyBinding } from "@fabricorg/policy-opa";
|
|
@@ -31,10 +36,13 @@ export const LENDING_POLICY_BINDINGS = {
|
|
|
31
36
|
version: 1,
|
|
32
37
|
decisionPath: "fabric/lending/tcpa/contact_window/decision",
|
|
33
38
|
regoPackage: "fabric.lending.tcpa.contact_window",
|
|
39
|
+
bundleName: "lending",
|
|
34
40
|
},
|
|
35
41
|
} satisfies Record<string, OpaPolicyBinding>;
|
|
36
42
|
```
|
|
37
43
|
|
|
44
|
+
`bundleName` is optional. Set it when your OPA serves multiple bundles and you need audit evidence to record exactly which bundle's revision produced the decision. If OPA serves only one bundle, omit `bundleName` and the adapter uses that bundle's revision unambiguously.
|
|
45
|
+
|
|
38
46
|
### 2. The fact builder (TypeScript)
|
|
39
47
|
|
|
40
48
|
Snapshot the facts OPA needs from your database. OPA never touches your DB directly — your TypeScript host owns reads, tenant scoping, and redaction.
|
|
@@ -70,14 +78,18 @@ decision := {
|
|
|
70
78
|
"result": result,
|
|
71
79
|
"reason": reason,
|
|
72
80
|
"conditionResults": condition_results,
|
|
73
|
-
"
|
|
81
|
+
"guidance": {
|
|
82
|
+
"summary": reason,
|
|
83
|
+
"factsUsed": facts_used,
|
|
84
|
+
"correctiveActions": corrective_actions,
|
|
85
|
+
},
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
result := "block" if not consent_active
|
|
77
89
|
result := "block" if not within_window
|
|
78
90
|
default result := "pass"
|
|
79
91
|
|
|
80
|
-
# ...rules computing reason, condition_results, facts_used...
|
|
92
|
+
# ...rules computing reason, condition_results, facts_used, corrective_actions...
|
|
81
93
|
```
|
|
82
94
|
|
|
83
95
|
The validator accepts the following fields (TypeScript types: `OpaDecision`):
|
|
@@ -92,7 +104,7 @@ The validator accepts the following fields (TypeScript types: `OpaDecision`):
|
|
|
92
104
|
result: "pass" | "warn" | "block";
|
|
93
105
|
reason?: string;
|
|
94
106
|
}>;
|
|
95
|
-
|
|
107
|
+
guidance?: PolicyGuidance;
|
|
96
108
|
obligations?: Record<string, unknown>;
|
|
97
109
|
}
|
|
98
110
|
```
|
|
@@ -117,12 +129,19 @@ registerPolicy(tcpaPolicy);
|
|
|
117
129
|
|
|
118
130
|
### Host wiring
|
|
119
131
|
|
|
120
|
-
The runtime host injects
|
|
132
|
+
The runtime host injects an OPA executor (or client) into `PolicyContext.services` when it builds a policy context.
|
|
133
|
+
|
|
134
|
+
**Recommended — HTTP executor with full provenance:**
|
|
121
135
|
|
|
122
136
|
```ts
|
|
123
|
-
import {
|
|
137
|
+
import { executorFromOpaHttp } from "@fabricorg/policy-opa";
|
|
124
138
|
|
|
125
|
-
const opa =
|
|
139
|
+
const opa = executorFromOpaHttp({
|
|
140
|
+
baseUrl: process.env.OPA_URL ?? "http://localhost:8181",
|
|
141
|
+
// provenance: true is the default — `?provenance=true` is appended to
|
|
142
|
+
// every request so OPA returns decision_id and bundle revisions.
|
|
143
|
+
// headers: { authorization: `Bearer ${process.env.OPA_TOKEN}` },
|
|
144
|
+
});
|
|
126
145
|
|
|
127
146
|
// when constructing PolicyContext for evaluatePolicyDefinitions:
|
|
128
147
|
const policyCtx = {
|
|
@@ -133,9 +152,21 @@ const policyCtx = {
|
|
|
133
152
|
};
|
|
134
153
|
```
|
|
135
154
|
|
|
155
|
+
**Alternative — wrap the high-level SDK:**
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { OPAClient } from "@open-policy-agent/opa";
|
|
159
|
+
import { executorFromOpaClient } from "@fabricorg/policy-opa";
|
|
160
|
+
|
|
161
|
+
const opa = executorFromOpaClient(new OPAClient(process.env.OPA_URL ?? "http://localhost:8181"));
|
|
162
|
+
// ... services: { opa }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This works (the adapter accepts an `OpaPolicyClient` directly too, so wrapping isn't strictly required), but the high-level SDK discards `decision_id` and `provenance` — audit evidence is thinner than with `executorFromOpaHttp`.
|
|
166
|
+
|
|
136
167
|
## What lands in audit evidence
|
|
137
168
|
|
|
138
|
-
For a successful OPA evaluation, `PolicyDispatchEvidence.engine` looks like:
|
|
169
|
+
For a successful OPA evaluation under the HTTP executor (with provenance enabled), `PolicyDispatchEvidence.engine` looks like:
|
|
139
170
|
|
|
140
171
|
```json
|
|
141
172
|
{
|
|
@@ -145,13 +176,21 @@ For a successful OPA evaluation, `PolicyDispatchEvidence.engine` looks like:
|
|
|
145
176
|
"decisionPath": "fabric/lending/tcpa/contact_window/decision",
|
|
146
177
|
"regoPackage": "fabric.lending.tcpa.contact_window",
|
|
147
178
|
"inputHash": "sha256:8a3c…",
|
|
148
|
-
"
|
|
149
|
-
"
|
|
179
|
+
"decisionId": "01J0Q9MZ…",
|
|
180
|
+
"bundleName": "lending",
|
|
181
|
+
"bundleRevision": "2026-05-16.3",
|
|
182
|
+
"provenance": {
|
|
183
|
+
"version": "0.65.0",
|
|
184
|
+
"bundles": { "lending": { "revision": "2026-05-16.3" } }
|
|
185
|
+
},
|
|
186
|
+
"conditionResults": [{ "conditionId": "consent_active", "passed": true, "result": "pass" }]
|
|
150
187
|
}
|
|
151
188
|
}
|
|
152
189
|
```
|
|
153
190
|
|
|
154
|
-
Fabric's `policyId` / `policyVersion` remain canonical (`"what was evaluated"`). `engine.metadata` answers `"which artifact produced the answer"
|
|
191
|
+
Fabric's `policyId` / `policyVersion` remain canonical (`"what was evaluated"`). `engine.metadata.{decisionId, bundleName, bundleRevision}` answers `"which artifact produced the answer"`, and `decisionId` is the join key against OPA's own decision-log stream if you ship it to a SIEM. User-facing facts and remediation steps live on `PolicyOutcome.guidance`, not in engine metadata, so code policies and OPA policies render through the same audit UI contract.
|
|
192
|
+
|
|
193
|
+
If OPA serves multiple bundles and the binding has no `bundleName` selector, `bundleRevision` is omitted and `bundleRevisionStatus: "ambiguous"` is recorded — audit dashboards can flag these for a manual fix.
|
|
155
194
|
|
|
156
195
|
## Failure modes
|
|
157
196
|
|
|
@@ -171,9 +210,9 @@ OPA evaluation is a network call, so the adapter defaults `previewSafe: false`.
|
|
|
171
210
|
## What this package does NOT do
|
|
172
211
|
|
|
173
212
|
- It doesn't talk to your database. `buildInput` is yours.
|
|
174
|
-
- It doesn't run an OPA server. You bring an OPA sidecar (or remote service) and inject
|
|
213
|
+
- It doesn't run an OPA server. You bring an OPA sidecar (or remote service) and inject an executor or client.
|
|
175
214
|
- It doesn't ship Rego policies. You author and bundle those.
|
|
176
|
-
- It doesn't
|
|
215
|
+
- It doesn't auto-discover bundle names. Bind one explicitly with `binding.bundleName` if OPA serves multiple bundles, or accept the `"ambiguous"` status in evidence.
|
|
177
216
|
|
|
178
217
|
## License
|
|
179
218
|
|
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
var crypto = require('crypto');
|
|
4
4
|
|
|
5
|
+
// src/bundle-selector.ts
|
|
6
|
+
function selectBundleRevision(execution, binding) {
|
|
7
|
+
if (typeof execution.bundleRevision === "string" && execution.bundleRevision.length > 0) {
|
|
8
|
+
return { bundleRevision: execution.bundleRevision };
|
|
9
|
+
}
|
|
10
|
+
const bundles = readBundles(execution.provenance);
|
|
11
|
+
if (!bundles) return {};
|
|
12
|
+
const names = Object.keys(bundles);
|
|
13
|
+
if (names.length === 0) return {};
|
|
14
|
+
if (binding.bundleName) {
|
|
15
|
+
const revision = bundles[binding.bundleName]?.revision;
|
|
16
|
+
return typeof revision === "string" && revision.length > 0 ? { bundleRevision: revision } : {};
|
|
17
|
+
}
|
|
18
|
+
if (names.length === 1) {
|
|
19
|
+
const only = names[0];
|
|
20
|
+
const revision = only !== void 0 ? bundles[only]?.revision : void 0;
|
|
21
|
+
return typeof revision === "string" && revision.length > 0 ? { bundleRevision: revision } : {};
|
|
22
|
+
}
|
|
23
|
+
return { bundleRevisionStatus: "ambiguous" };
|
|
24
|
+
}
|
|
25
|
+
function readBundles(provenance) {
|
|
26
|
+
if (!provenance || typeof provenance !== "object") return void 0;
|
|
27
|
+
const b = provenance.bundles;
|
|
28
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return void 0;
|
|
29
|
+
return b;
|
|
30
|
+
}
|
|
31
|
+
|
|
5
32
|
// src/decision-schema.ts
|
|
6
33
|
var POLICY_RESULTS = ["pass", "warn", "block"];
|
|
7
34
|
function parseOpaDecision(raw) {
|
|
@@ -27,8 +54,9 @@ function parseOpaDecision(raw) {
|
|
|
27
54
|
if (err) return { ok: false, error: err };
|
|
28
55
|
}
|
|
29
56
|
}
|
|
30
|
-
if (d.
|
|
31
|
-
|
|
57
|
+
if (d.guidance !== void 0) {
|
|
58
|
+
const err = validateGuidance(d.guidance);
|
|
59
|
+
if (err) return { ok: false, error: err };
|
|
32
60
|
}
|
|
33
61
|
if (d.obligations !== void 0 && !isPlainObject(d.obligations)) {
|
|
34
62
|
return { ok: false, error: "obligations must be an object when present" };
|
|
@@ -54,6 +82,68 @@ function validateConditionResult(value, index) {
|
|
|
54
82
|
}
|
|
55
83
|
return null;
|
|
56
84
|
}
|
|
85
|
+
function validateGuidance(value) {
|
|
86
|
+
if (!isPlainObject(value)) {
|
|
87
|
+
return "guidance must be an object when present";
|
|
88
|
+
}
|
|
89
|
+
const guidance = value;
|
|
90
|
+
if (guidance.summary !== void 0 && typeof guidance.summary !== "string") {
|
|
91
|
+
return "guidance.summary must be a string when present";
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(guidance.factsUsed)) {
|
|
94
|
+
return "guidance.factsUsed must be an array";
|
|
95
|
+
}
|
|
96
|
+
for (let i = 0; i < guidance.factsUsed.length; i += 1) {
|
|
97
|
+
const err = validatePolicyFact(guidance.factsUsed[i], i);
|
|
98
|
+
if (err) return err;
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(guidance.correctiveActions)) {
|
|
101
|
+
return "guidance.correctiveActions must be an array";
|
|
102
|
+
}
|
|
103
|
+
for (let i = 0; i < guidance.correctiveActions.length; i += 1) {
|
|
104
|
+
const err = validateCorrectiveAction(guidance.correctiveActions[i], i);
|
|
105
|
+
if (err) return err;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function validatePolicyFact(value, index) {
|
|
110
|
+
if (!isPlainObject(value)) {
|
|
111
|
+
return `guidance.factsUsed[${index}] must be an object`;
|
|
112
|
+
}
|
|
113
|
+
const fact = value;
|
|
114
|
+
if (typeof fact.name !== "string" || fact.name.length === 0) {
|
|
115
|
+
return `guidance.factsUsed[${index}].name must be a non-empty string`;
|
|
116
|
+
}
|
|
117
|
+
if (fact.label !== void 0 && typeof fact.label !== "string") {
|
|
118
|
+
return `guidance.factsUsed[${index}].label must be a string when present`;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function validateCorrectiveAction(value, index) {
|
|
123
|
+
if (!isPlainObject(value)) {
|
|
124
|
+
return `guidance.correctiveActions[${index}] must be an object`;
|
|
125
|
+
}
|
|
126
|
+
const action = value;
|
|
127
|
+
if (typeof action.label !== "string" || action.label.length === 0) {
|
|
128
|
+
return `guidance.correctiveActions[${index}].label must be a non-empty string`;
|
|
129
|
+
}
|
|
130
|
+
if (action.description !== void 0 && typeof action.description !== "string") {
|
|
131
|
+
return `guidance.correctiveActions[${index}].description must be a string when present`;
|
|
132
|
+
}
|
|
133
|
+
if (action.actionId !== void 0 && typeof action.actionId !== "string") {
|
|
134
|
+
return `guidance.correctiveActions[${index}].actionId must be a string when present`;
|
|
135
|
+
}
|
|
136
|
+
if (action.parameters !== void 0 && !isPlainObject(action.parameters)) {
|
|
137
|
+
return `guidance.correctiveActions[${index}].parameters must be an object when present`;
|
|
138
|
+
}
|
|
139
|
+
if (action.target !== void 0 && !isPlainObject(action.target)) {
|
|
140
|
+
return `guidance.correctiveActions[${index}].target must be an object when present`;
|
|
141
|
+
}
|
|
142
|
+
if (action.requiresConfirmation !== void 0 && typeof action.requiresConfirmation !== "boolean") {
|
|
143
|
+
return `guidance.correctiveActions[${index}].requiresConfirmation must be a boolean when present`;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
57
147
|
function isPlainObject(value) {
|
|
58
148
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
149
|
}
|
|
@@ -91,13 +181,14 @@ function createOpaPolicyEvaluator(options) {
|
|
|
91
181
|
version: binding.version,
|
|
92
182
|
previewSafe,
|
|
93
183
|
evaluate: async (ctx) => {
|
|
94
|
-
const
|
|
95
|
-
|
|
184
|
+
const service = ctx.services?.[serviceKey];
|
|
185
|
+
const transport = resolveTransport(service);
|
|
186
|
+
if (!transport) {
|
|
96
187
|
return makeFailureOutcome(
|
|
97
188
|
binding,
|
|
98
189
|
engineVersion,
|
|
99
190
|
"client_unavailable",
|
|
100
|
-
`No OPA client at ctx.services["${serviceKey}"]`,
|
|
191
|
+
`No OPA client/executor at ctx.services["${serviceKey}"]`,
|
|
101
192
|
onError
|
|
102
193
|
);
|
|
103
194
|
}
|
|
@@ -118,9 +209,9 @@ function createOpaPolicyEvaluator(options) {
|
|
|
118
209
|
inputHash = hashInput(input);
|
|
119
210
|
} catch {
|
|
120
211
|
}
|
|
121
|
-
let
|
|
212
|
+
let execution;
|
|
122
213
|
try {
|
|
123
|
-
|
|
214
|
+
execution = await transport(binding.decisionPath, input);
|
|
124
215
|
} catch (err) {
|
|
125
216
|
return makeFailureOutcome(
|
|
126
217
|
binding,
|
|
@@ -131,7 +222,7 @@ function createOpaPolicyEvaluator(options) {
|
|
|
131
222
|
inputHash
|
|
132
223
|
);
|
|
133
224
|
}
|
|
134
|
-
const parsed = parseOpaDecision(
|
|
225
|
+
const parsed = parseOpaDecision(execution.decision);
|
|
135
226
|
if (!parsed.ok) {
|
|
136
227
|
return makeFailureOutcome(
|
|
137
228
|
binding,
|
|
@@ -139,13 +230,15 @@ function createOpaPolicyEvaluator(options) {
|
|
|
139
230
|
"invalid_response",
|
|
140
231
|
`OPA returned malformed decision: ${parsed.error}`,
|
|
141
232
|
onError,
|
|
142
|
-
inputHash
|
|
233
|
+
inputHash,
|
|
234
|
+
execution
|
|
143
235
|
);
|
|
144
236
|
}
|
|
145
237
|
return buildSuccessOutcome(
|
|
146
238
|
binding,
|
|
147
239
|
engineVersion,
|
|
148
240
|
parsed.decision,
|
|
241
|
+
execution,
|
|
149
242
|
inputHash,
|
|
150
243
|
ctx,
|
|
151
244
|
mapDecision
|
|
@@ -153,13 +246,29 @@ function createOpaPolicyEvaluator(options) {
|
|
|
153
246
|
}
|
|
154
247
|
};
|
|
155
248
|
}
|
|
156
|
-
function
|
|
157
|
-
|
|
249
|
+
function resolveTransport(service) {
|
|
250
|
+
if (!service || typeof service !== "object") return null;
|
|
251
|
+
const exec = service.execute;
|
|
252
|
+
if (typeof exec === "function") {
|
|
253
|
+
return (path, input) => service.execute(path, input);
|
|
254
|
+
}
|
|
255
|
+
const eval_ = service.evaluate;
|
|
256
|
+
if (typeof eval_ === "function") {
|
|
257
|
+
return async (path, input) => ({
|
|
258
|
+
decision: await service.evaluate(path, input)
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
function buildSuccessOutcome(binding, engineVersion, decision, execution, inputHash, ctx, mapDecision) {
|
|
264
|
+
const selection = selectBundleRevision(execution, binding);
|
|
265
|
+
const mapped = mapDecision ? mapDecision(decision, ctx, execution) : { result: decision.result, reason: decision.reason };
|
|
158
266
|
return {
|
|
159
267
|
policyId: binding.policyId,
|
|
160
268
|
policyVersion: binding.version,
|
|
161
|
-
result: mapped.result,
|
|
162
|
-
reason: mapped.reason,
|
|
269
|
+
result: mapped.result ?? decision.result,
|
|
270
|
+
reason: mapped.reason ?? decision.reason,
|
|
271
|
+
guidance: mapped.guidance ?? decision.guidance,
|
|
163
272
|
metadata: {
|
|
164
273
|
...mapped.metadata ?? {},
|
|
165
274
|
opaDecision: decision
|
|
@@ -171,30 +280,48 @@ function buildSuccessOutcome(binding, engineVersion, decision, inputHash, ctx, m
|
|
|
171
280
|
dispatchPath: ["code"],
|
|
172
281
|
engine: {
|
|
173
282
|
name: ENGINE_NAME,
|
|
174
|
-
version: engineVersion,
|
|
283
|
+
version: execution.opaVersion ?? engineVersion,
|
|
175
284
|
metadata: stripUndefined({
|
|
176
285
|
decisionPath: binding.decisionPath,
|
|
177
286
|
regoPackage: binding.regoPackage,
|
|
178
287
|
inputHash,
|
|
288
|
+
decisionId: execution.decisionId,
|
|
289
|
+
bundleName: binding.bundleName,
|
|
290
|
+
bundleRevision: selection.bundleRevision,
|
|
291
|
+
bundleRevisionStatus: selection.bundleRevisionStatus,
|
|
292
|
+
provenance: execution.provenance,
|
|
179
293
|
conditionResults: decision.conditionResults,
|
|
180
|
-
factsUsed: decision.factsUsed,
|
|
181
294
|
obligations: decision.obligations
|
|
182
295
|
})
|
|
183
296
|
}
|
|
184
297
|
}
|
|
185
298
|
};
|
|
186
299
|
}
|
|
187
|
-
function makeFailureOutcome(binding, engineVersion, error, reason, onError, inputHash) {
|
|
300
|
+
function makeFailureOutcome(binding, engineVersion, error, reason, onError, inputHash, execution) {
|
|
188
301
|
if (onError === "throw") {
|
|
189
302
|
const err = new Error(reason);
|
|
190
303
|
err.kind = error;
|
|
191
304
|
throw err;
|
|
192
305
|
}
|
|
306
|
+
const selection = execution ? selectBundleRevision(execution, binding) : {};
|
|
193
307
|
return {
|
|
194
308
|
policyId: binding.policyId,
|
|
195
309
|
policyVersion: binding.version,
|
|
196
310
|
result: "block",
|
|
197
311
|
reason,
|
|
312
|
+
guidance: {
|
|
313
|
+
summary: "OPA policy evaluation could not complete.",
|
|
314
|
+
factsUsed: [
|
|
315
|
+
{ name: "opaFailureKind", value: error, label: "Failure kind" },
|
|
316
|
+
{ name: "decisionPath", value: binding.decisionPath, label: "Decision path" }
|
|
317
|
+
],
|
|
318
|
+
correctiveActions: [
|
|
319
|
+
{
|
|
320
|
+
label: "Check OPA policy engine",
|
|
321
|
+
description: "Verify the OPA service is reachable, serving the expected bundle, and returning the Fabric OPA decision shape."
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
},
|
|
198
325
|
metadata: {
|
|
199
326
|
opaError: error
|
|
200
327
|
},
|
|
@@ -205,11 +332,16 @@ function makeFailureOutcome(binding, engineVersion, error, reason, onError, inpu
|
|
|
205
332
|
dispatchPath: ["code"],
|
|
206
333
|
engine: {
|
|
207
334
|
name: ENGINE_NAME,
|
|
208
|
-
version: engineVersion,
|
|
335
|
+
version: execution?.opaVersion ?? engineVersion,
|
|
209
336
|
metadata: stripUndefined({
|
|
210
337
|
decisionPath: binding.decisionPath,
|
|
211
338
|
regoPackage: binding.regoPackage,
|
|
212
339
|
inputHash,
|
|
340
|
+
decisionId: execution?.decisionId,
|
|
341
|
+
bundleName: binding.bundleName,
|
|
342
|
+
bundleRevision: selection.bundleRevision,
|
|
343
|
+
bundleRevisionStatus: selection.bundleRevisionStatus,
|
|
344
|
+
provenance: execution?.provenance,
|
|
213
345
|
error,
|
|
214
346
|
errorReason: reason
|
|
215
347
|
})
|
|
@@ -234,9 +366,77 @@ function formatError(err) {
|
|
|
234
366
|
}
|
|
235
367
|
}
|
|
236
368
|
|
|
369
|
+
// src/executor-client.ts
|
|
370
|
+
function executorFromOpaClient(client) {
|
|
371
|
+
return {
|
|
372
|
+
async execute(path, input) {
|
|
373
|
+
const decision = await client.evaluate(path, input);
|
|
374
|
+
return { decision };
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/executor-http.ts
|
|
380
|
+
function executorFromOpaHttp(opts) {
|
|
381
|
+
const baseUrl = stripTrailingSlash(opts.baseUrl);
|
|
382
|
+
const provenance = opts.provenance ?? true;
|
|
383
|
+
const customFetch = opts.fetch;
|
|
384
|
+
const headers = {
|
|
385
|
+
"content-type": "application/json",
|
|
386
|
+
accept: "application/json",
|
|
387
|
+
...opts.headers ?? {}
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
async execute(path, input) {
|
|
391
|
+
const fetchImpl = customFetch ?? globalThis.fetch;
|
|
392
|
+
if (typeof fetchImpl !== "function") {
|
|
393
|
+
throw new Error(
|
|
394
|
+
"executorFromOpaHttp: no fetch implementation available. Pass `fetch` in options or run on a runtime with `globalThis.fetch`."
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const url = `${baseUrl}/v1/data/${stripLeadingSlash(path)}${provenance ? "?provenance=true" : ""}`;
|
|
398
|
+
const response = await fetchImpl(url, {
|
|
399
|
+
method: "POST",
|
|
400
|
+
headers,
|
|
401
|
+
body: JSON.stringify({ input })
|
|
402
|
+
});
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
const text = await safeReadText(response);
|
|
405
|
+
throw new Error(`OPA HTTP ${response.status} at ${url}: ${truncate(text, 500)}`);
|
|
406
|
+
}
|
|
407
|
+
const body = await response.json();
|
|
408
|
+
return {
|
|
409
|
+
decision: body.result,
|
|
410
|
+
decisionId: body.decision_id,
|
|
411
|
+
opaVersion: body.provenance?.version,
|
|
412
|
+
provenance: body.provenance
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function stripTrailingSlash(s) {
|
|
418
|
+
return s.replace(/\/+$/, "");
|
|
419
|
+
}
|
|
420
|
+
function stripLeadingSlash(s) {
|
|
421
|
+
return s.replace(/^\/+/, "");
|
|
422
|
+
}
|
|
423
|
+
async function safeReadText(response) {
|
|
424
|
+
try {
|
|
425
|
+
return await response.text();
|
|
426
|
+
} catch {
|
|
427
|
+
return "<unreadable body>";
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function truncate(s, max) {
|
|
431
|
+
return s.length <= max ? s : `${s.slice(0, max)}\u2026`;
|
|
432
|
+
}
|
|
433
|
+
|
|
237
434
|
exports.createOpaPolicyEvaluator = createOpaPolicyEvaluator;
|
|
238
435
|
exports.defaultHashInput = defaultHashInput;
|
|
436
|
+
exports.executorFromOpaClient = executorFromOpaClient;
|
|
437
|
+
exports.executorFromOpaHttp = executorFromOpaHttp;
|
|
239
438
|
exports.parseOpaDecision = parseOpaDecision;
|
|
439
|
+
exports.selectBundleRevision = selectBundleRevision;
|
|
240
440
|
exports.stableStringify = stableStringify;
|
|
241
441
|
//# sourceMappingURL=index.cjs.map
|
|
242
442
|
//# sourceMappingURL=index.cjs.map
|