Security as Code: Getting Started with OPA and Rego — Part 1

OPA
Facebooktwitterredditlinkedin

Why Security Policies Belong in Code — Not Documents

Security policies have traditionally lived in documents — PDFs, wikis, and runbooks that describe what should happen. Engineers read them sometimes, interpret them inconsistently, and implement them manually. The gap between documented policy and enforced reality is exactly where breaches are born.

Security as Code closes that gap. It means expressing security policies as machine-readable, version-controlled, automatically enforced rules — the same way you treat application code. Instead of a wiki page that says “no container should run as root,” you write a policy that prevents a container from running as root, enforced automatically on every single deployment.

At the center of this movement is Open Policy Agent (OPA) and its companion language, Rego. This is Part 1 of a two-part series:

  • Part 1 (this post): What OPA is, how Rego works, and your first real Kubernetes policies
  • Part 2 — Advanced OPA: Terraform enforcement, API authorization, CI/CD integration, OPA Gatekeeper, and testing at scale

Let’s build from the ground up.


What Is Open Policy Agent (OPA)?

OPA (pronounced “oh-pa”) is an open-source, general-purpose policy engine, now a graduated CNCF project. Its job is simple: make policy decisions.

OPA decouples where decisions are made from where decisions are enforced. Here’s the core flow:

Your Service / Tool
       │
       │  "Is this allowed?"  ──────▶  OPA Engine
       │  (JSON input)                      │
       │                                    │  Evaluates Rego policy
       │                                    │
       │◀──────────────────────────────────  Decision (allow / deny + reason)

OPA takes a JSON input (a Kubernetes pod spec, an API request, a Terraform plan), evaluates it against your Rego policies, and returns a decision. Your service then acts on that decision — blocking the deployment, rejecting the API call, failing the pipeline.

OPA doesn’t know or care what your service is. It’s a pure decision engine. The enforcement happens in your integration layer.

Why OPA Over Custom Logic?

You could write policy checks directly in your application. Most teams do, initially. The problems that follow:

  • Policy logic scatters across codebases, teams, and repositories
  • No unified language or testing framework
  • Policy changes require application deployments
  • Impossible to audit “what is our actual policy right now” across the organization

OPA centralizes policy into a single, auditable, testable, version-controlled system that enforces consistently across your entire stack.


Understanding Rego: OPA’s Policy Language

Rego is a declarative, logic-based language purpose-built for expressing policies. Unlike imperative languages where you describe how to compute something, Rego describes what must be true.

The Building Blocks

1. Rules

A Rego policy is a set of rules. Each rule defines a logical expression that OPA evaluates.

package example

# "allow" is true only when BOTH conditions hold
allow {
    input.user == "admin"
    input.action == "read"
}

Multiple conditions inside a rule are implicitly AND. If either fails, the rule body fails.

2. The input Document

input is the JSON payload sent to OPA for evaluation. OPA evaluates your policy against it. You define its shape; OPA reads it.

{
  "user": "admin",
  "action": "read"
}

3. Multiple Rules = OR

If you define the same rule name multiple times, OPA treats it as OR — the rule is true if any definition matches.

allow {
    input.user == "admin"
}

allow {
    input.user == "superuser"
}

4. Default Values and Deny-First Design

A security best practice in OPA: default to deny, and only allow what you explicitly permit.

package authz

default allow = false

allow {
    not deny
}

deny {
    input.request.method == "DELETE"
    not is_admin
}

is_admin {
    input.user.roles[_] == "admin"
}

The [_] syntax is Rego’s way of iterating — it means “for any element in this array.”

5. Iteration with [_]

# "some_container_is_privileged" is true if ANY container is privileged
some_container_is_privileged {
    container := input.spec.containers[_]
    container.securityContext.privileged == true
}

6. Comprehensions

Rego supports set, array, and object comprehensions — powerful for collecting matching elements.

# Collect all containers that are missing resource limits
containers_without_limits := {c.name |
    c := input.spec.containers[_]
    not c.resources.limits
}

Your First Real Policy: Block Privileged Containers

Let’s write something you’d actually deploy in production.

Scenario: No container in any pod should be allowed to run in privileged mode. A privileged container has full access to the host system — it’s essentially root on the node.

package kubernetes.admission

deny[msg] {
    # Iterate over every container in the pod spec
    container := input.request.object.spec.containers[_]

    # Fail if privileged is explicitly set to true
    container.securityContext.privileged == true

    # Build a human-readable denial message
    msg := sprintf(
        "Container '%v' in pod '%v' must not run in privileged mode.",
        [container.name, input.request.object.metadata.name]
    )
}

This is a deny rule — it produces a set of violation messages. If the set is empty, the request is allowed. If it contains any message, the request is denied and the message is returned to the caller.

Testing it — OPA’s built-in test framework:

package kubernetes.admission_test

test_privileged_container_denied {
    count(deny) > 0 with input as {
        "request": {
            "object": {
                "metadata": {"name": "web-pod"},
                "spec": {
                    "containers": [{
                        "name": "nginx",
                        "securityContext": {"privileged": true}
                    }]
                }
            }
        }
    }
}

test_non_privileged_container_passes {
    count(deny) == 0 with input as {
        "request": {
            "object": {
                "metadata": {"name": "web-pod"},
                "spec": {
                    "containers": [{
                        "name": "nginx",
                        "securityContext": {"privileged": false}
                    }]
                }
            }
        }
    }
}

Run your tests:

# Install OPA
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static
chmod +x opa && sudo mv opa /usr/local/bin/

# Run tests with verbose output
opa test ./policies/ -v

Your Second Policy: Enforce Required Labels

Without consistent labeling, you lose cost attribution, incident ownership, and compliance tagging. This policy makes labels a hard requirement — not a suggestion.

rego

package kubernetes.admission

required_labels := {"owner", "environment", "team"}

deny[msg] {
    input.request.kind.kind == "Deployment"

    # Compute which required labels are absent
    missing := required_labels - {label |
        input.request.object.metadata.labels[label]
    }

    count(missing) > 0

    msg := sprintf(
        "Deployment '%v' is missing required labels: %v",
        [input.request.object.metadata.name, missing]
    )
}

What’s happening here:

  • required_labels is a Rego set — like a list, but with unique elements and set operations
  • The comprehension {label | input.request.object.metadata.labels[label]} builds a set of labels that exist on the resource
  • The - operator computes the set difference — labels required but not present

This is a real-world policy. Add it to your admission webhook and no deployment reaches your cluster without owner, environment, and team labels.


Running OPA Locally

Before wiring OPA into Kubernetes, get comfortable running it locally.

Start OPA as a server:

opa run --server ./policies/

Query a policy via the REST API:

curl -X POST http://localhost:8181/v1/data/kubernetes/admission/deny \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "request": {
        "kind": {"kind": "Deployment"},
        "object": {
          "metadata": {
            "name": "my-app",
            "labels": {"owner": "team-a"}
          },
          "spec": {"containers": []}
        }
      }
    }
  }'

Evaluate a policy inline (great for debugging):

opa eval \
  --input input.json \
  --data policies/ \
  "data.kubernetes.admission.deny"

Structuring Your Policy Repository

Even with two policies, structure matters. Start organized and you won’t need to refactor later.

policies/
├── kubernetes/
│   ├── admission/
│   │   ├── no_privileged.rego
│   │   └── required_labels.rego
│   └── tests/
│       ├── no_privileged_test.rego
│       └── required_labels_test.rego
└── data/
    └── approved_registries.json

The rule: every .rego policy file gets a _test.rego sibling. No exceptions.


Key Rego Concepts Cheat Sheet

ConceptSyntaxMeaning
Rule (AND)allow { a; b }True only if both a and b hold
Multiple rules (OR)allow { a } allow { b }True if either rule matches
Defaultdefault allow = falseValue when no rule matches
Iterationx := arr[_]For each element in arr
Set comprehension{x | arr[x]}Build a set from matching elements
Set differenceA - BElements in A but not in B
Negationnot exprTrue when expr is undefined or false
String formatsprintf("%v", [val])Format a string with values

What’s Next: Part 2

You now understand OPA’s architecture, Rego’s core building blocks, and have two production-ready Kubernetes policies. That’s a solid foundation.

In Part 2, we go further:

  • Terraform policy enforcement with Conftest — block non-compliant infrastructure before terraform apply
  • API authorization with OPA — externalize RBAC away from your application code
  • OPA Gatekeeper — manage policies as Kubernetes-native CRDs at scale
  • CI/CD pipeline integration — GitHub Actions workflows that gate every PR
  • Testing at scale — coverage reports, edge cases, and policy regression testing
  • Common pitfalls — the initContainers blind spot, undefined vs. false, and more

Further Reading:

Facebooktwitterredditlinkedin

Leave a Reply

Your email address will not be published. Required fields are marked *