@fabricorg/policy-opa
v0.4.0
Published
Open Policy Agent (OPA) adapter for @fabricorg/platform. Wraps a Rego decision behind Fabric's PolicyEvaluator contract — Fabric keeps policyId.vN canonical for audit; engine evidence carries OPA provenance (decision path, input hash, bundle revision). Ze
Downloads
504
Maintainers
Readme
@fabricorg/policy-opa
Open Policy Agent (OPA) adapter for @fabricorg/platform. Lets a Rego decision back a Fabric code policy while preserving Fabric's canonical audit identity (policyId.vN, policyVersion) and capturing OPA-side provenance (decisionPath, inputHash, optional version) under PolicyDispatchEvidence.engine.
The platform stays zero-dependency and vendor-neutral; the OPA-specific glue lives here.
Install
pnpm add @fabricorg/platform @fabricorg/policy-opa
# Optionally the official SDK if you want the high-level client shape:
pnpm add @open-policy-agent/opa@fabricorg/policy-opa has zero runtime dependencies. Two wiring shapes are supported:
OpaPolicyExecutor(recommended) —execute(path, input) → OpaPolicyExecution. Surfaces full OPA-side provenance (decisionId,bundleRevision,opaVersion, rawprovenanceblock) underengine.metadata. The package shipsexecutorFromOpaHttp({ baseUrl, ... })for this.OpaPolicyClient—evaluate(path, input) → Promise<unknown>. Structurally compatible with the high-level@open-policy-agent/opaSDK. Simpler to wire but the SDK discardsdecisionIdandprovenancefrom the response, so audit evidence is thinner.
If a service implements both methods, execute is preferred.
Usage — the four pieces
A complete OPA-backed evaluator has four pieces, three of which the vertical owns.
1. The vertical policy manifest
Keep Fabric policy IDs, versions, decision paths, Rego package names, and bundle selectors in one place. Don't inline strings at call sites.
import type { OpaPolicyBinding } from "@fabricorg/policy-opa";
export const LENDING_POLICY_BINDINGS = {
tcpaContactWindow: {
policyId: "lending.tcpa_contact_window.v1",
version: 1,
decisionPath: "fabric/lending/tcpa/contact_window/decision",
regoPackage: "fabric.lending.tcpa.contact_window",
bundleName: "lending",
},
} satisfies Record<string, OpaPolicyBinding>;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.
2. The fact builder (TypeScript)
Snapshot the facts OPA needs from your database. OPA never touches your DB directly — your TypeScript host owns reads, tenant scoping, and redaction.
import type { PolicyContext } from "@fabricorg/platform/policies";
async function buildContactWindowInput(ctx: PolicyContext) {
const consent = await ctx.db.tcpaConsent.findUnique({
where: { partyId: ctx.parameters.partyId },
});
return {
tenantId: ctx.tenantId,
actionId: ctx.actionId,
now: (ctx.now ?? new Date()).toISOString(),
consent: consent
? { active: consent.status === "active", scope: consent.scope }
: null,
contactWindow: { startHour: 8, endHour: 21, timezone: ctx.parameters.timezone },
};
}3. The Rego policy (house style)
Have Rego return a structured decision object, not a bare boolean. The adapter validates against this shape.
package fabric.lending.tcpa.contact_window
import rego.v1
decision := {
"result": result,
"reason": reason,
"conditionResults": condition_results,
"guidance": {
"summary": reason,
"factsUsed": facts_used,
"correctiveActions": corrective_actions,
},
}
result := "block" if not consent_active
result := "block" if not within_window
default result := "pass"
# ...rules computing reason, condition_results, facts_used, corrective_actions...The validator accepts the following fields (TypeScript types: OpaDecision):
{
result: "pass" | "warn" | "block"; // required
reason?: string;
conditionResults?: Array<{
conditionId: string;
passed: boolean;
result: "pass" | "warn" | "block";
reason?: string;
}>;
guidance?: PolicyGuidance;
obligations?: Record<string, unknown>;
}Every guidance.correctiveActions[] item must include kind:
invoke_action: requiresactionIdand optionalparameters.navigate: optionalrouteor structuredtarget.manual: human instruction with no automated operation.
4. The evaluator factory (this package)
import { createOpaPolicyEvaluator } from "@fabricorg/policy-opa";
import { registerPolicy } from "@fabricorg/platform/policies";
import { LENDING_POLICY_BINDINGS } from "../policies/manifest";
const tcpaPolicy = createOpaPolicyEvaluator({
binding: LENDING_POLICY_BINDINGS.tcpaContactWindow,
buildInput: buildContactWindowInput,
serviceKey: "opa", // default; ctx.services.opa must hold an OpaPolicyClient
onError: "block", // default; honors Fabric's default-deny posture
});
registerPolicy(tcpaPolicy);Host wiring
The runtime host injects an OPA executor (or client) into PolicyContext.services when it builds a policy context.
Recommended — HTTP executor with full provenance:
import { executorFromOpaHttp } from "@fabricorg/policy-opa";
const opa = executorFromOpaHttp({
baseUrl: process.env.OPA_URL ?? "http://localhost:8181",
// provenance: true is the default — `?provenance=true` is appended to
// every request so OPA returns decision_id and bundle revisions.
// headers: { authorization: `Bearer ${process.env.OPA_TOKEN}` },
});
// when constructing PolicyContext for evaluatePolicyDefinitions:
const policyCtx = {
tenantId, spaceId, actionInvocationId, actionId, parameters,
db,
mode: "execute",
services: { opa },
};Alternative — wrap the high-level SDK:
import { OPAClient } from "@open-policy-agent/opa";
import { executorFromOpaClient } from "@fabricorg/policy-opa";
const opa = executorFromOpaClient(new OPAClient(process.env.OPA_URL ?? "http://localhost:8181"));
// ... services: { opa }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.
What lands in audit evidence
For a successful OPA evaluation under the HTTP executor (with provenance enabled), PolicyDispatchEvidence.engine looks like:
{
"name": "opa",
"version": "0.65.0",
"metadata": {
"decisionPath": "fabric/lending/tcpa/contact_window/decision",
"regoPackage": "fabric.lending.tcpa.contact_window",
"inputHash": "sha256:8a3c…",
"decisionId": "01J0Q9MZ…",
"bundleName": "lending",
"bundleRevision": "2026-05-16.3",
"provenance": {
"version": "0.65.0",
"bundles": { "lending": { "revision": "2026-05-16.3" } }
},
"conditionResults": [{ "conditionId": "consent_active", "passed": true, "result": "pass" }]
}
}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.
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.
Failure modes
| Situation | Result | engine.metadata.error |
|---|---|---|
| ctx.services[serviceKey] missing or not an OpaPolicyClient | block | client_unavailable |
| buildInput(ctx) throws | block | input_build_failed |
| OPA call throws (network, timeout, 5xx) | block | evaluation_failed |
| OPA returns a shape that doesn't match OpaDecision | block | invalid_response |
Default behavior is onError: "block" — Fabric's default-deny posture. Pass onError: "throw" to propagate the error and let Fabric surface it as a failed invocation instead.
Preview mode
OPA evaluation is a network call, so the adapter defaults previewSafe: false. Fabric's runtime will skip the evaluator in preview mode and yield a warn outcome with structured skip evidence — you don't need to opt out of preview manually. Pass previewSafe: true only if your OPA topology guarantees deterministic, side-effect-free evaluation appropriate for preview.
What this package does NOT do
- It doesn't talk to your database.
buildInputis yours. - It doesn't run an OPA server. You bring an OPA sidecar (or remote service) and inject an executor or client.
- It doesn't ship Rego policies. You author and bundle those.
- It doesn't auto-discover bundle names. Bind one explicitly with
binding.bundleNameif OPA serves multiple bundles, or accept the"ambiguous"status in evidence.
License
MIT — see LICENSE.
