Policy as Code in Practice: Open Policy Agent, Rego, and Automated Compliance Enforcement in CI/CD Pipelines
How to encode security, compliance, and operational policies as version-controlled code using OPA, Rego, and TypeScript. Covers Kubernetes admission control, Terraform plan validation, API authorization, CI/CD gates, policy testing, and production tradeoffs against Cedar and Sentinel.
The compliance conversation used to go like this: a security team wrote a Word document, engineers read it once, then everyone forgot about it until the audit. The policies existed, but they were not enforced at the point where decisions are made. They were enforced at the point where someone thought to check.
Policy as code flips that. You write the rules in a language the machine can evaluate, commit them alongside your application code, and enforce them at the exact moment a relevant action is attempted. Not after the fact. Not in a quarterly review. At the moment Kubernetes is asked to admit a pod, or Terraform is asked to plan an infrastructure change, or a service is asked to issue a token.
Open Policy Agent (OPA) is the dominant general-purpose policy engine in this space. This article covers its architecture, how to write Rego policies for the most common enforcement points, how to call OPA from TypeScript, how to test policies with OPA’s built-in framework, and where the boundaries are between OPA, Cedar, and Sentinel.
OPA Architecture
OPA separates policy evaluation from policy enforcement. The architecture has three components working together.
The decision engine is a stateless service (or embeddable library) that accepts a query, evaluates it against loaded policy and data, and returns a decision. It does not make network calls, does not write to a database, and has no side effects. This is deliberate: policy evaluation must be deterministic and fast.
The policy language (Rego) is a declarative logic language built for this use case. Rego is not Turing-complete in the traditional sense: it has no loops (use comprehensions instead), no mutable state, and no exceptions. Every policy is a set of logical rules that either hold or do not hold given the input.
Data binding is how context enters the engine. OPA loads two kinds of data: policy documents (your .rego files) and data documents (JSON). Data documents can contain anything: a list of approved registries, a mapping of team to resource ownership, a set of known-bad CVEs. OPA evaluates queries against both. This lets you separate the logic (which never changes) from the data (which changes frequently).
Writing Rego Policies
Kubernetes Admission Control
OPA integrates with Kubernetes via a ValidatingWebhookConfiguration that forwards admission requests to OPA (or to Gatekeeper, which wraps OPA for Kubernetes). The policy receives the full admission request and must return allow or a denial with a message.
A policy that blocks containers running as root and blocks images not from an approved registry:
package kubernetes.admission
import rego.v1
# Approved registries — source this from a data document in production
approved_registries := {"registry.example.com", "gcr.io/my-project"}
deny contains msg if {
some container in input.request.object.spec.containers
not container_from_approved_registry(container)
msg := sprintf("container %q uses an unapproved registry", [container.name])
}
deny contains msg if {
some container in input.request.object.spec.containers
runs_as_root(container)
msg := sprintf("container %q must not run as root", [container.name])
}
container_from_approved_registry(container) if {
some registry in approved_registries
startswith(container.image, registry)
}
runs_as_root(container) if {
container.securityContext.runAsUser == 0
}
runs_as_root(container) if {
not container.securityContext.runAsNonRoot
not container.securityContext.runAsUser
}
A few non-obvious decisions in this policy. The deny contains msg pattern uses OPA’s set semantics: if multiple rules fire, all their messages accumulate. The admission response will contain every violation, not just the first one. This is far more useful for the engineer who submitted the pod spec and needs to know what to fix.
The runs_as_root helper has two clauses. Both can be true independently, and Rego evaluates each as a separate candidate. If either holds, runs_as_root holds. This is the correct model for “no runAsNonRoot: true AND no explicit runAsUser” without nesting conditionals.
Load the registry list from a data document rather than hardcoding it:
approved_registries := data.kubernetes.approved_registries
Then push a JSON data document to OPA via its API:
{
"kubernetes": {
"approved_registries": [
"registry.example.com",
"gcr.io/my-project"
]
}
}
This separates the policy logic (which requires a code review) from the registry list (which the platform team might update weekly).
Terraform Plan Validation
Calling OPA against a Terraform plan catches infrastructure violations before apply. The workflow: terraform plan -out=plan.bin, then terraform show -json plan.bin > plan.json, then opa eval against the plan.
A policy that requires all S3 buckets to have versioning enabled and blocks public access:
package terraform.aws
import rego.v1
deny contains msg if {
some resource in input.resource_changes
resource.type == "aws_s3_bucket"
resource.change.actions[_] in {"create", "update"}
not versioning_enabled(resource.change.after)
msg := sprintf("S3 bucket %q must have versioning enabled", [resource.address])
}
deny contains msg if {
some resource in input.resource_changes
resource.type == "aws_s3_bucket_public_access_block"
resource.change.actions[_] in {"create", "update"}
config := resource.change.after
not all_public_access_blocked(config)
msg := sprintf("S3 bucket public access block %q must block all public access", [resource.address])
}
versioning_enabled(config) if {
config.versioning[_].enabled == true
}
all_public_access_blocked(config) if {
config.block_public_acls == true
config.block_public_policy == true
config.ignore_public_acls == true
config.restrict_public_buckets == true
}
The Terraform plan JSON structure nests deeply. resource.change.after is the post-apply state of the resource. resource.change.actions is a list that might contain "create", "update", "delete", or "no-op". Filtering to create and update means you check only resources being modified, not ones being destroyed or left unchanged.
Integrate this into CI with a step that fails the pipeline if any deny messages are returned:
opa eval \
--data policies/terraform/ \
--input plan.json \
--format raw \
'count(data.terraform.aws.deny) == 0'
If the expression evaluates to false, the exit code is non-zero and the pipeline fails.
API Authorization
OPA excels at fine-grained authorization decisions. A service asks OPA: “can this principal perform this action on this resource?” OPA evaluates the question against loaded policy and data and returns a decision. The service enforces it.
A policy for a multi-tenant API where users can only access resources belonging to their organization:
package api.authz
import rego.v1
default allow := false
allow if {
input.method == "GET"
resource_belongs_to_caller_org
not resource_is_deleted
}
allow if {
input.method in {"POST", "PUT", "PATCH"}
resource_belongs_to_caller_org
caller_has_write_permission
}
allow if {
input.method == "DELETE"
resource_belongs_to_caller_org
caller_has_admin_role
}
resource_belongs_to_caller_org if {
resource := data.resources[input.resource_id]
resource.org_id == input.claims.org_id
}
resource_is_deleted if {
data.resources[input.resource_id].deleted_at != null
}
caller_has_write_permission if {
some role in data.roles[input.claims.user_id]
role in {"writer", "admin"}
}
caller_has_admin_role if {
some role in data.roles[input.claims.user_id]
role == "admin"
}
The data.resources and data.roles documents are loaded separately and updated as your application data changes. OPA can pull these via bundle distribution (covered below) or you can push updates via its REST API.
TypeScript Integration
OPA runs as a sidecar in Kubernetes or as a standalone service. Calling it from a TypeScript service is a simple HTTP call to the evaluation endpoint.
interface OpaInput {
method: string;
resource_id: string;
claims: {
user_id: string;
org_id: string;
};
}
interface OpaResult<T> {
result: T;
}
async function evaluate<T>(
policy: string,
input: OpaInput
): Promise<T> {
const response = await fetch(`${process.env.OPA_URL}/v1/data/${policy}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
if (!response.ok) {
throw new Error(`OPA evaluation failed: ${response.status}`);
}
const body = (await response.json()) as OpaResult<T>;
return body.result;
}
// In your authorization middleware:
async function authorize(req: Request): Promise<boolean> {
const decision = await evaluate<{ allow: boolean }>(
"api/authz",
{
method: req.method,
resource_id: req.params.id,
claims: req.auth.claims,
}
);
return decision.allow;
}
For environments where a sidecar is not viable, OPA compiles to WebAssembly. The WASM module evaluates policies in-process with no network round-trip:
import { loadPolicy } from "@open-policy-agent/opa-wasm";
import { readFile } from "fs/promises";
let policy: Awaited<ReturnType<typeof loadPolicy>> | null = null;
async function getPolicy() {
if (!policy) {
const wasmBytes = await readFile("./authz.wasm");
policy = await loadPolicy(wasmBytes);
}
return policy;
}
async function evaluateWasm(input: OpaInput): Promise<boolean> {
const p = await getPolicy();
const result = p.evaluate(input);
return result[0]?.result?.allow ?? false;
}
Compile the WASM artifact as part of your build step:
opa build \
--target wasm \
--entrypoint api/authz/allow \
policies/api/authz.rego \
-o authz.tar.gz
The WASM path is valuable when policy evaluation latency is critical. You eliminate the HTTP round-trip and the serialization overhead, at the cost of embedding OPA’s WASM runtime in your process and managing the policy artifact as a build artifact rather than a separately deployed service.
Policy Testing
OPA ships with a test framework. Tests are Rego files with rules named test_*:
package kubernetes.admission_test
import rego.v1
mock_container_approved := {
"name": "app",
"image": "registry.example.com/app:v1.2.3",
"securityContext": {"runAsUser": 1000, "runAsNonRoot": true},
}
mock_container_root := {
"name": "app",
"image": "registry.example.com/app:v1.2.3",
"securityContext": {"runAsUser": 0},
}
mock_container_unapproved := {
"name": "app",
"image": "docker.io/nginx:latest",
"securityContext": {"runAsUser": 1000},
}
mock_request(containers) := {
"request": {
"object": {
"spec": {"containers": containers},
},
},
}
test_approved_container_passes if {
result := data.kubernetes.admission.deny with input as mock_request([mock_container_approved])
count(result) == 0
}
test_root_container_denied if {
result := data.kubernetes.admission.deny with input as mock_request([mock_container_root])
some msg in result
contains(msg, "must not run as root")
}
test_unapproved_registry_denied if {
result := data.kubernetes.admission.deny with input as mock_request([mock_container_unapproved])
some msg in result
contains(msg, "unapproved registry")
}
Run tests with:
opa test policies/ -v
OPA reports each test’s pass/fail status and shows the failing rule’s trace on failure. Coverage is available via --coverage. Aim for 100% coverage on deny rules: a missing test case for a deny rule means a missing enforcement guarantee.
Policy Bundles and Distribution
In production, you do not push policy files to individual OPA instances. You build bundles and distribute them.
A bundle is a tarball containing policy files and data documents with a manifest. OPA polls a bundle server (an S3 bucket, an HTTP endpoint, or OCI registry) and reloads policy when the bundle changes. This gives you centralized policy management across hundreds of OPA instances.
Build a bundle:
opa build \
--bundle \
policies/ \
--output bundle.tar.gz
Configure OPA to pull from S3:
services:
s3:
url: https://s3.amazonaws.com
credentials:
s3_signing:
environment_credentials: {}
bundles:
main:
service: s3
resource: my-policy-bucket/bundles/main/bundle.tar.gz
polling:
min_delay_seconds: 60
max_delay_seconds: 120
Bundle signing is available for environments where policy integrity is a compliance requirement. Sign the bundle with a key, configure OPA with the public key, and OPA will reject bundles that do not verify.
Tradeoffs: OPA vs Cedar vs Sentinel
These three engines address overlapping but distinct use cases.
| Dimension | OPA | Cedar (AWS) | Sentinel (HashiCorp) |
|---|---|---|---|
| Policy language | Rego (logic programming) | Cedar (explicit allow/deny, entity model) | Sentinel (imperative-ish DSL) |
| Primary use case | General purpose: Kubernetes, APIs, Terraform | AWS resource authorization, Amazon Verified Permissions | HashiCorp product suite (Terraform Cloud, Vault, Consul) |
| Data model | Arbitrary JSON documents | Typed entity and action model | Module imports from the platform |
| Performance | Sub-millisecond with WASM; ~1-5ms as sidecar | Managed service; low but variable latency | Embedded in HashiCorp products |
| Portability | Runs anywhere, language-agnostic | AWS-ecosystem, SDK available for self-hosting | HashiCorp toolchain only |
| Learning curve | High (Rego requires a mental model shift) | Medium (explicit entity model is intuitive) | Medium (familiar to anyone who has written policy in CI) |
| Testing | Built-in test framework | Local testing via Cedar CLI | Built-in test framework |
| Best fit | Platform-level policy across diverse enforcement points | Authorization for AWS-native applications using IAM-like semantics | Enforcing operational policies within the HashiCorp ecosystem |
OPA’s Rego language is where most teams struggle. The shift from imperative (“if this then that”) to declarative (“these conditions must all hold”) takes time. The most common mistake is writing rules that look like if/else chains. In Rego, every rule that evaluates to true contributes to the result. Writing a second clause in a deny rule does not mean “else”: it means a separate independent condition that also causes a denial.
Cedar is worth evaluating if your authorization model maps cleanly onto entities, actions, and resources with explicit allow/deny semantics. Its type system catches policy errors at validation time rather than at evaluation time, which matters for high-stakes authorization.
Sentinel is the right choice when your enforcement surface is exclusively within HashiCorp products. Its integration is deep and opinionated, which is a benefit when you are already invested in that stack and a constraint if you are not.
Production Considerations
Performance at scale. OPA evaluates in under 1ms for most policies when data is pre-loaded. The bottleneck is usually data loading, not evaluation. If your data documents are large (millions of records), load only the relevant slice. OPA supports partial evaluation: pre-compute the policy for a given subject and cache the result rather than re-evaluating with full data on every request.
Policy versioning. Treat policies like application code: version control, code review, semantic versioning for bundles. Break changes into separate policy packages and deprecate old ones with a migration window. A policy change that tightens a previously-loose rule will cause admission failures for workloads that were already running. Run new policies in warn-only mode first, audit the violations, then switch to enforcement.
Audit logging. OPA’s decision log captures every evaluation: the input, the policy used, the data version, and the result. Enable it. Feed it to your SIEM or log aggregator. This is what you hand to the auditor when they ask “how do you know that policy X was enforced for all traffic in Q3?” OPA’s decision log entries include a bundles field with bundle revision hashes, giving you a verifiable record of which policy version made each decision.
decision_logs:
console: true
service: logging-service
reporting:
min_delay_seconds: 5
max_delay_seconds: 30
Debugging policy decisions. When a policy denies something it should not, use opa eval with --explain full to trace every rule that was evaluated, what data was bound, and why each rule succeeded or failed. For Kubernetes admission failures, OPA returns the denial message in the admission response, but the trace is not included. Add a structured logging step in your Gatekeeper configuration or run the policy locally with the admission request JSON to trace it.
Partial evaluation for authorization. If you are using OPA for API authorization with millions of users and resources, per-request evaluation with full data documents does not scale. OPA’s partial evaluation produces a simplified, residual policy (a set of conditions) for a given set of known inputs. Cache that residual and evaluate it against the unknown inputs at request time. This reduces the data surface OPA must load per decision.
Policy drift between environments. The bundle model solves most of this: all environments pull from the same bundle server and you promote bundles through environments with version tags. The failure mode is environment-specific data documents that mask policy problems. If your staging environment has a different set of approved registries than production, a policy that passes in staging may fail in production. Keep environment-specific data minimal and review it alongside policy changes.
The Actual Value
Policies in documents enforce nothing. A JIRA ticket asking engineers to “follow the security guidelines” enforces nothing. OPA running as a Kubernetes admission controller enforces something: it stops the non-compliant pod before it runs, gives the engineer a specific error message, and logs the decision with enough context to audit later.
The compounding value is that policies in version control accumulate. Each time a security incident reveals a new failure mode, you write a policy. The policy prevents the same failure from recurring on any cluster, in any environment, automatically, without requiring anyone to remember to check. That is the model that survives organizational growth.
The overhead is real: Rego has a learning curve, bundle distribution adds operational complexity, and policy testing requires discipline. But the alternative is policies that exist as text and enforcement that exists as hope. For teams operating at any meaningful scale, the tradeoff resolves quickly in favor of encoding the rules the machine can check.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.