How to Configure PolicyEvaluator with Custom OPA/Cedar Policy Backends for Multi-Org Governance

The Agent Governance Toolkit's PolicyEngine supports custom OPA (Rego) and Cedar policy backends through option structs (OPAOptions, CedarOptions) registered via LoadRego and LoadCedar, enabling isolated per-tenant policy evaluation with automatic CLI-to-mock fallbacks.

The Microsoft Agent Governance Toolkit provides a flexible authorization framework where the PolicyEngine evaluates native policy rules first and delegates to external backends when no match exists. By configuring custom OPA and Cedar backends, organizations can implement multi-organization governance where each tenant maintains isolated policy sets while sharing the same runtime infrastructure.

Understanding the Policy Engine Architecture

The Policy Engine (agentmesh.PolicyEngine) serves as the central decision-making component. It evaluates native PolicyRule objects first, and when no rule matches, iterates through registered external backends in order until a decision is reached.

The toolkit ships with two interchangeable backends:

  • OPA (Open Policy Agent): Evaluates Rego policies via remote HTTP, CLI, or built-in mock evaluator (OPABackend)
  • Cedar: Evaluates Cedar DSL policies via CLI or built-in mock evaluator (CedarBackend)

Both backends implement the same interface and are registered with the engine through dedicated loader methods defined in policy.go:72-79.

Configuring OPA and Cedar Backends

Backend configuration occurs through option structs that specify execution mode, policy paths, and fallback behavior.

OPA Backend Configuration

Instantiate an OPA backend using NewOPABackend with an OPAOptions struct:

b := NewOPABackend(OPAOptions{
    Mode:        OPAAuto,               // auto → CLI if present, else built-in mock
    RegoPath:    "/policies/allow.rego", // or supply RegoContent directly
    Package:     "myorg",
    Query:       "data.myorg.allow",
    Timeout:     3 * time.Second,
    AllowBuiltinFallback: true,          // enable safe fallback when CLI missing
})

According to policy_backends.go:72-98, the constructor parses the Rego file if RegoContent is empty, validates the OPA URL (rejecting non-HTTP schemes and SSRF-prone hosts), and sets sensible defaults. When Mode is set to OPARemote, the backend sends evaluation requests to the configured OPA server via POST to /v1/data/....

Cedar Backend Configuration

Configure Cedar policies using NewCedarBackend with CedarOptions:

b := NewCedarBackend(CedarOptions{
    Mode:        CedarAuto,
    PolicyPath:  "/policies/allow.cedar",
    EntitiesPath: "/policies/entities.json", // optional
    Timeout:     4 * time.Second,
    AllowBuiltinFallback: true,
})

As implemented in policy_backends.go:95-121, the builder reads the policy file and optional entities file, logs any I/O errors, and defaults to the built-in evaluator when the Cedar CLI is unavailable.

Evaluation Flow and Decision Logic

When PolicyEngine.Evaluate(action, ctx) is called, the execution follows a strict precedence order:

  1. The engine clones the supplied context and enriches it with protocol-specific facets.
  2. It scans native PolicyRules for a match. If found, the rule's effect (allow/deny/review) wins immediately.
  3. If no native rule matches, the engine builds a backend context guaranteeing the presence of action and tool_name keys (policy.go:18-25).
  4. It iterates over registered backends in insertion order (policy.go:30-40).
  5. Each backend's Evaluate method returns a BackendDecision containing Allowed, Decision, optional Reason, and raw result data.

Errors from any backend are treated as a deny (fail-closed) per policy.go:32-35. The decision from each backend is normalized (normalizeBackendDecision in policy.go:22-30), and the first non-allowing result terminates the loop, ensuring all backends must allow for a final Allow decision.

Implementing Multi-Organization Governance

To support multiple organizations (tenants) with distinct policy sets, instantiate a separate PolicyEngine per tenant, each loading its own OPA/Cedar policies:

func NewTenantEngine(orgID string) *PolicyEngine {
    pe := NewPolicyEngine(nil)

    // Load OPA policy for this organisation
    pe.LoadRego(OPAOptions{
        Mode:        OPAAuto,
        RegoPath:    fmt.Sprintf("/configs/%s/opa.rego", orgID),
        Package:     orgID,
        Query:       fmt.Sprintf("data.%s.allow", orgID),
        AllowBuiltinFallback: true,
    })

    // Load Cedar policy for this organisation (optional)
    pe.LoadCedar(CedarOptions{
        Mode:        CedarAuto,
        PolicyPath:  fmt.Sprintf("/configs/%s/cedar.cedar", orgID),
        EntitiesPath: fmt.Sprintf("/configs/%s/entities.json", orgID),
        AllowBuiltinFallback: true,
    })

    return pe
}

At request time, the dispatcher fetches the proper engine by tenant ID and calls engine.Evaluate(action, ctx). Because each engine holds its own backend instances, policies are isolated per organization while sharing the same runtime code.

Fallback Mechanisms and Safety Guarantees

Auto mode (OPAAuto / CedarAuto) prefers the CLI (which provides the most complete feature set) but falls back to the built-in mock evaluator when the CLI binary is missing, if AllowBuiltinFallback is true. This behavior is demonstrated in the tests (policy_backends_test.go:84-92 for OPA and policy_backends_test.go:45-53 for Cedar).

The mock evaluators implement a strict subset of each language and fail closed on unsupported constructs, guaranteeing security even when a fallback occurs.

Complete Implementation Examples

Registering Multiple Backends in a Single Engine

pe := NewPolicyEngine(nil)

// OPA backend – use remote server for high-scale evaluation
pe.LoadRego(OPAOptions{
    Mode:   OPARemote,
    OPAURL: "https://opa.myorg.example:8181",
    Query:  "data.myorg.allow",
    Timeout: 2 * time.Second,
})

// Cedar backend – CLI fallback with builtin mock as safety net
pe.LoadCedar(CedarOptions{
    Mode:   CedarAuto,
    PolicyPath: "./policy.cedar",
    EntitiesPath: "./entities.json",
    AllowBuiltinFallback: true,
})

// Evaluate a request from tenant "acme":
ctx := map[string]interface{}{
    "agent_id": "user123",
    "tenant":   "acme",
    "tool_name": "data.read",
}
decision := pe.Evaluate("data.read", ctx)
fmt.Println("Decision:", decision) // Allow / Deny / Review …

Per-Tenant Engine Caching with sync.Map

var engines = sync.Map{} // map[string]*PolicyEngine

func GetEngine(org string) *PolicyEngine {
    if v, ok := engines.Load(org); ok {
        return v.(*PolicyEngine)
    }
    engine := NewTenantEngine(org)
    engines.Store(org, engine)
    return engine
}

// In an HTTP handler:
func evalHandler(w http.ResponseWriter, r *http.Request) {
    org := r.Header.Get("X-Org-ID")
    action := r.URL.Query().Get("action")
    ctx := map[string]interface{}{
        "agent_id": r.Header.Get("X-Agent-ID"),
        "tenant":   org,
    }
    decision := GetEngine(org).Evaluate(action, ctx)
    json.NewEncoder(w).Encode(map[string]string{"decision": string(decision)})
}

Summary

  • Backend Registration: Use LoadRego and LoadCedar in policy.go:72-79 to register external policy backends with the PolicyEngine.
  • Configuration Patterns: Pass OPAOptions or CedarOptions to configure execution mode (OPAAuto, OPARemote, CedarAuto), policy paths, and timeouts.
  • Multi-Org Isolation: Create separate PolicyEngine instances per tenant, each loading organization-specific policy files, stored in a concurrent map for request-time retrieval.
  • Safety Defaults: Both backends support AllowBuiltinFallback to ensure availability, with mock evaluators that fail closed on unsupported language features.
  • Evaluation Order: Native rules evaluate first, followed by backends in registration order; all must allow for final authorization.

Frequently Asked Questions

What is the difference between OPAAuto and OPARemote modes?

OPAAuto automatically detects the OPA CLI and falls back to a built-in mock evaluator if unavailable, while OPARemote sends evaluation requests to a specified OPA server URL via HTTP POST. According to policy_backends.go:93-118, OPARemote validates URLs to prevent SSRF attacks by rejecting non-HTTP schemes.

How does the PolicyEngine handle backend evaluation failures?

The engine implements fail-closed semantics where any backend error results in an immediate deny decision. As noted in policy.go:32-35, errors during backend evaluation terminate the evaluation loop and return a deny status, ensuring that policy engine unavailability does not result in unauthorized access.

Can I use both OPA and Cedar backends simultaneously in the same engine?

Yes, the PolicyEngine supports multiple concurrent backends registered via sequential calls to LoadRego and LoadCedar. The engine evaluates backends in the order they were added, requiring all backends to return allow for a final authorization, as demonstrated in the reference example at examples/policy-opa-cedar/main.go.

How do I ensure complete policy isolation between organizations?

Instantiate a distinct PolicyEngine per organization using tenant-specific configuration paths, and store these instances in a concurrent map keyed by organization ID. Since each engine maintains its own backend instances and loaded policies, no memory is shared between tenants, guaranteeing isolation while maximizing code reuse.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →