Security enforcement logic tends to spread. Access control checks live in application code. Admission rules live in shell scripts. IAM conditions live in JSON buried in Terraform. Kubernetes RBAC lives in YAML files that no one reviews. Over time the policy surface becomes impossible to audit, test, or reason about consistently.

Policy as Code addresses this by treating security rules as first-class, version-controlled, testable code — evaluated by a dedicated policy engine rather than scattered across every system that needs to make an authorization decision.

Open Policy Agent (OPA) is the most widely adopted engine for this pattern. This post covers how to design with it effectively: the data model, the evaluation model, integration patterns, and the failure modes that matter in production.

What OPA actually does

OPA is a general-purpose policy engine. You give it:

  1. A policy — written in Rego, OPA’s declarative language
  2. Input — a JSON document describing the request being evaluated
  3. Data — context OPA can query during evaluation (asset inventory, user roles, etc.)

OPA evaluates the policy against the input and data and returns a decision. That decision can be a boolean, a structured object, a list — whatever your policy defines.

Input (JSON)  +  Policy (Rego)  +  Data (JSON)  →  Decision (JSON)

The engine has no built-in concept of users, resources, or actions. Those are defined by your policy. This makes OPA applicable across a wide range of enforcement points: Kubernetes admission, API gateways, CI/CD pipelines, infrastructure provisioning, and autonomous agent systems.

The Rego data model

Rego is a declarative language built on Datalog. The key mental model shift from imperative languages: you define what is true, not what to do.

A policy that allows read access to non-production resources:

package authz

default allow = false

allow {
    input.action == "read"
    input.resource.environment != "prod"
    valid_principal
}

valid_principal {
    input.principal.role == "engineer"
}

valid_principal {
    input.principal.role == "analyst"
}

This policy has no if/else, no loops, no mutation. Each rule defines a condition under which a value is true. If any rule body evaluates to true, the rule head is true. Multiple rules with the same head are implicitly OR’d.

The evaluation model: OPA evaluates all rules and returns the result. There is no short-circuit, no ordering dependency, no hidden state. Given the same input and data, the same policy always returns the same decision.

Structuring policies for real systems

A flat policy file works for toy examples. Production systems need structure.

Separate packages by enforcement domain. Each enforcement point gets its own package:

policy/
├── authz/
│   └── api_gateway.rego        # API request authorization
├── admission/
│   └── kubernetes.rego         # Kubernetes admission control
├── cloud/
│   ├── iam.rego                # IAM policy evaluation
│   └── storage.rego            # Storage access decisions
└── autonomous/
    └── remediation.rego        # Agent action authorization

Separate policy from data. Policies should not hardcode lists of approved values. They should query external data:

package authz.api_gateway

import data.approved_services
import data.user_roles

default allow = false

allow {
    service := approved_services[input.service_id]
    role    := user_roles[input.principal_id]
    role.permissions[_] == input.action
}

The approved_services and user_roles documents are loaded into OPA separately — from a database, a config map, or a bundle. The policy expresses how to evaluate; the data expresses what is true at a given point in time.

Layer allow and deny explicitly. OPA has no built-in precedence between allow and deny rules. You define the logic:

package cloud.storage

default decision = "deny"

decision = "allow" {
    allow
    not deny
}

allow {
    input.action == "read"
    input.resource.classification != "restricted"
}

deny {
    input.resource.environment == "prod"
    input.principal.type == "service_account"
    not input.principal.approved_for_prod
}

Explicit layering makes the precedence visible in the policy, not hidden in evaluation order.

Kubernetes admission control

OPA integrates with Kubernetes via the admission webhook. Every resource creation or update passes through OPA before it is committed to the cluster.

A policy that blocks containers running as root:

package kubernetes.admission

deny[msg] {
    input.request.kind.kind == "Pod"
    container := input.request.object.spec.containers[_]
    not container.securityContext.runAsNonRoot

    msg := sprintf("container '%v' must set runAsNonRoot: true", [container.name])
}

A policy that requires all deployments to have resource limits:

deny[msg] {
    input.request.kind.kind == "Deployment"
    container := input.request.object.spec.template.spec.containers[_]
    not container.resources.limits

    msg := sprintf("container '%v' must define resource limits", [container.name])
}

The deny[msg] pattern collects all violations as a set of messages rather than short-circuiting on the first. This means a single rejected request returns all the reasons it was rejected — useful for developer feedback.

CI/CD pipeline enforcement

Admission control catches problems at deploy time. Policy checks in CI catch them earlier, when the cost of a fix is lower.

The pattern: run OPA against infrastructure-as-code output (Terraform plan, Helm chart, Kubernetes manifests) in CI before the deployment reaches the cluster.

# evaluate a Terraform plan against a policy bundle
terraform show -json plan.tfplan | opa eval \
  --data policy/ \
  --input /dev/stdin \
  "data.terraform.deny"

A policy that flags public S3 buckets in Terraform plans:

package terraform

deny[msg] {
    resource := input.resource_changes[_]
    resource.type == "aws_s3_bucket_public_access_block"
    resource.change.after.block_public_acls == false

    msg := sprintf("resource '%v' must block public ACLs", [resource.address])
}

The same policy language, the same engine, the same evaluation model — applied at a different point in the delivery pipeline.

Policy for autonomous agent systems

The pattern that matters most for agentic systems: the policy engine as the decision boundary between AI reasoning and infrastructure action.

An agent produces a remediation plan. The plan is evaluated against an OPA policy before any action is taken. The agent cannot bypass this — it has no credentials, no direct access to infrastructure. It can only submit plans and receive decisions.

package autonomous.remediation

default allow       = false
default requires_approval = false
default deny        = false

# Hard denies — no path to allow
deny {
    input.blast_radius == "high"
}

deny {
    input.action_type == "delete_resource"
}

# Production requires a human in the loop
requires_approval {
    input.environment == "prod"
    not deny
}

# Automated allow: non-prod, high confidence, rollback available
allow {
    input.environment != "prod"
    input.confidence  == "high"
    input.rollback_available == true
    not deny
}

The policy version is logged with every decision. When investigating an incident, you can reconstruct exactly what policy was in effect at the time of each action — which rule matched, which data was evaluated. This is the auditability property that makes autonomous action trustworthy.

Testing policies

Untested policies are configuration. The Rego unit test framework makes policies verifiable:

package autonomous.remediation_test

test_deny_high_blast_radius {
    deny with input as {
        "action_type":        "block_public_s3_access",
        "blast_radius":       "high",
        "environment":        "dev",
        "confidence":         "high",
        "rollback_available": true
    }
}

test_allow_non_prod_s3_block {
    allow with input as {
        "action_type":        "block_public_s3_access",
        "blast_radius":       "low",
        "environment":        "dev",
        "confidence":         "high",
        "rollback_available": true
    }
}

test_requires_approval_in_prod {
    requires_approval with input as {
        "action_type":        "block_public_s3_access",
        "blast_radius":       "low",
        "environment":        "prod",
        "confidence":         "high",
        "rollback_available": true
    }
}

Run with:

opa test policy/ -v

Policy changes go through the same review process as application code — diff, review, test, merge. This is the core value proposition: security rules become auditable, reviewable, and testable like any other code.

What breaks in practice

Policy and data drift. The policy assumes a data schema. The data changes. Nothing enforces the contract between them. Mitigation: schema validation on data bundles; tests that cover the boundary conditions that depend on specific data shapes.

Overly broad defaults. default allow = false is correct but teams sometimes flip it for convenience and never flip it back. Make the default explicit in every package, not just the root.

Missing the enforcement point. OPA decides; something else must enforce. If a service ignores the OPA decision, the policy is theatre. The integration must be verified — ideally with an integration test that confirms a denied request actually fails.

Policy explosion without structure. A single flat policy file becomes unmanageable past a few hundred lines. Package structure and data separation are not premature optimisation — they are prerequisites for a policy layer that a team can maintain over time.


The value of a policy engine is not that it makes authorization easier to write. It is that it makes authorization possible to audit, test, and evolve independently of the systems it governs. When a policy change needs to go through review, when every decision is logged with the policy version that produced it, and when tests break before a bad rule reaches production — that is when policy as code pays for itself.