npm.io
0.4.3 • Published 9h ago

@fabricorg/policy-opa

Licence
MIT
Version
0.4.3
Deps
0
Size
157 kB
Vulns
0
Weekly
0

@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, raw provenance block) under engine.metadata. The package ships executorFromOpaHttp({ baseUrl, ... }) for this.
  • OpaPolicyClientevaluate(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.

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: requires actionId and optional parameters.
  • navigate: optional route or structured target.
  • 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. buildInput is 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.bundleName if OPA serves multiple bundles, or accept the "ambiguous" status in evidence.

License

MIT — see LICENSE.

Keywords