Back to Blog
Policy 2026-02-03

OPA/Rego Policy Engine

How iddio integrates Open Policy Agent for enterprise-grade policy evaluation. Build-tag gating, the PolicyEvaluator interface, shadow mode for safe rollouts, and fail-closed semantics.

Why OPA

Iddio’s built-in policy engine uses YAML rules. For most deployments, YAML is enough — it covers agent-scoped namespace access, tier mappings, and runbook assignments. But enterprise customers need more: policies that reference external data, time-based conditions, cross-cutting constraints, and the ability to express logic that YAML can’t represent.

Open Policy Agent (OPA) with its Rego policy language is the industry standard for this. Rather than building a custom expression language, iddio integrates OPA as an optional policy evaluator.

Build-Tag Gating

OPA adds a significant dependency tree. Most iddio users don’t need it. To keep the default binary small and dependency-free, OPA support is gated behind a Go build tag:

//go:build opa

package policy

import "github.com/open-policy-agent/opa/rego"

Build without OPA (default):

go build ./cmd/iddio
# Binary: ~12MB, no OPA dependency

Build with OPA:

go build -tags opa ./cmd/iddio
# Binary: ~28MB, includes OPA evaluator

The build tag is checked at compile time. If you build without opa, the OPA evaluator type doesn’t exist in the binary at all — not as dead code, but literally absent.

The PolicyEvaluator Interface

Iddio defines a PolicyEvaluator interface that both the YAML engine and OPA engine implement:

type PolicyEvaluator interface {
    Evaluate(ctx context.Context, input EvalInput) (EvalResult, error)
}

type EvalInput struct {
    Agent     string
    Method    string
    Path      string
    Resource  string
    Namespace string
    Tier      int
    Timestamp time.Time
    Labels    map[string]string
}

type EvalResult struct {
    Decision  string    // "allow", "deny", "escalate"
    Reason    string
    Duration  time.Duration
}

The proxy doesn’t know which evaluator is active. It calls Evaluate and acts on the result.

OPA Evaluator Implementation

The OPA evaluator loads Rego policies from a configurable directory, prepares a query, and evaluates it against the request input:

type OPAEvaluator struct {
    query rego.PreparedEvalQuery
}

func NewOPAEvaluator(policyDir string) (*OPAEvaluator, error) {
    r := rego.New(
        rego.Query("data.iddio.authz.decision"),
        rego.Load([]string{policyDir}, nil),
    )
    query, err := r.PrepareForEval(context.Background())
    if err != nil {
        return nil, fmt.Errorf("opa prepare: %w", err)
    }
    return &OPAEvaluator{query: query}, nil
}

Example Rego Policy

A Rego policy for iddio follows a simple convention: it must produce a decision object with action and reason fields:

package iddio.authz

default decision = {"action": "deny", "reason": "no matching rule"}

# Allow all reads
decision = {"action": "allow", "reason": "read operation"} {
    input.tier == 0
}

# Allow T1 operations during business hours
decision = {"action": "allow", "reason": "business hours runbook"} {
    input.tier == 1
    hour := time.clock(time.now_ns())[0]
    hour >= 9
    hour < 18
}

# Escalate writes in production namespaces
decision = {"action": "escalate", "reason": "production write"} {
    input.tier >= 2
    startswith(input.namespace, "prod")
}

# Deny break-glass in all namespaces
decision = {"action": "deny", "reason": "break-glass denied"} {
    input.tier == 4
}

Shadow Mode

Rolling out OPA policies in production is risky — a typo in Rego can block all traffic. Shadow mode lets you run the OPA evaluator alongside the YAML engine without affecting traffic:

policy:
  evaluator: yaml # primary (makes decisions)
  shadow_evaluator: opa # shadow (logged, not enforced)
  opa:
    policy_dir: /etc/iddio/opa/

In shadow mode:

  1. Both evaluators receive every request
  2. The YAML evaluator’s decision is enforced
  3. The OPA evaluator’s decision is logged alongside it
  4. Disagreements are flagged in the audit log
{
  "agent": "claude-code",
  "tier": 2,
  "decision": "escalate",
  "shadow_decision": "allow",
  "shadow_evaluator": "opa",
  "shadow_divergence": true
}

This lets you validate OPA policies against real traffic before switching over. When you’re confident the OPA policy produces correct results, change evaluator: opa and remove the shadow.

Fail-Closed Semantics

If the OPA evaluator encounters an error — policy compilation failure, evaluation timeout, or unexpected result format — it returns deny:

func (o *OPAEvaluator) Evaluate(ctx context.Context, input EvalInput) (EvalResult, error) {
    results, err := o.query.Eval(ctx, rego.EvalInput(input))
    if err != nil {
        return EvalResult{Decision: "deny", Reason: "opa evaluation error"}, nil
    }
    if len(results) == 0 {
        return EvalResult{Decision: "deny", Reason: "no opa result"}, nil
    }
    // Parse result...
}

This is fail-closed by design. A broken policy never results in unintended access. The error is logged, and the operator can see exactly what went wrong.

Hot Reload

OPA policies are hot-reloaded using the same fsnotify-based mechanism as YAML policies. When a .rego file in the policy directory changes, the evaluator recompiles and swaps atomically. Compilation errors trigger the last-known-good fallback.

Try It Yourself

Iddio is open source. Deploy a zero-trust command proxy for your AI agents in minutes.