Back
Policy-as-Code in Practice: OPA, Kyverno, and Guardrails That Don't Slow Teams Down

Author: Steven Crickman
Edited by: Vahe Aslanyan

1. Introduction: The Problem Policy-as-Code Solves

Every platform team eventually hits the same wall. Self-service infrastructure and CI/CD pipelines let developers move fast, but speed without guardrails produces public S3 buckets, unencrypted databases, untagged resources nobody can bill back to a team, and containers running as root in production. The traditional fix — a human security reviewer manually inspecting every pull request or Terraform plan — doesn't scale, and it reintroduces exactly the kind of ticket-queue bottleneck that self-service platforms exist to eliminate.

Policy-as-code is the alternative: expressing compliance, security, and operational rules as machine-evaluated code that runs automatically in the same pipelines developers already use, rather than as a checklist a human works through by hand. Done well, it's invisible on the happy path — a compliant change sails through in milliseconds — and only surfaces friction when something actually violates a rule, with a clear, specific error message explaining what and why.

This handbook covers two of the dominant tools in this space — Open Policy Agent (OPA), a general-purpose policy engine with its own query language, Rego; and Kyverno, a Kubernetes-native policy engine that uses plain YAML — along with the patterns, pitfalls, and organizational practices that determine whether policy-as-code becomes an accelerant or a bottleneck.

2. Why Policy-as-Code, and Why Now

2.1 From Manual Review to Structural Enforcement

Manual security and compliance review has three structural problems: it's slow (a human has to be available and context-switch into your change), it's inconsistent (different reviewers catch different things, and fatigue sets in on the fiftieth Terraform plan of the day), and it doesn't scale with the number of changes flowing through a platform. Policy-as-code addresses all three by moving the check to the moment of creation — in CI, at admission time, or during Terraform plan — where it's fast, consistent, and infinitely repeatable at zero marginal review cost.

2.2 The Shift-Left Principle

The single most important design decision in a policy-as-code program is where checks run. The further left (earlier in the pipeline) a check runs, the cheaper the failure is to fix — a policy violation caught in a developer's local pre-commit hook costs seconds to fix; the same violation caught by a runtime admission controller after a deploy has already been attempted costs a failed deployment and a context switch; the same violation caught by a nightly compliance scan after the resource has been running in production for a week costs an incident and a retroactive fix. This handbook returns to this principle throughout: policy-as-code is most valuable not because it blocks bad things, but because it blocks them at the cheapest possible point in the pipeline.

2.3 Where This Fits in the Platform Stack

Policy-as-code isn't a standalone practice — it's a layer that sits inside CI/CD pipelines, GitOps reconciliation loops, and Kubernetes admission control, validating the artifacts (Terraform plans, Kubernetes manifests, container images) that flow through them. It complements, rather than replaces, the self-service infrastructure patterns (golden paths, scaffolded templates) that reduce how often a bad default gets requested in the first place — policy-as-code is the safety net underneath, catching the exceptions and edge cases a golden path's defaults don't cover.

3. Open Policy Agent (OPA) and Rego

3.1 What OPA Is

OPA is a general-purpose policy engine, not tied to any single system. It ingests structured data (JSON-like input) and evaluates it against policies written in Rego, a declarative query language purpose-built for expressing rules over nested data structures. Because OPA's input format is generic, the same engine can evaluate Terraform plans, Kubernetes manifests, API request payloads, or CI/CD pipeline configuration — anywhere you can serialize the thing you want to check into JSON.

OPA typically runs in one of three modes:

  • As a CLI (opa eval, or via the conftest wrapper), evaluating static files like Terraform plan JSON or Kubernetes YAML in a CI pipeline.
  • As a server, queried over HTTP by an application at decision time (e.g., "should this API request be allowed?").
  • As a Kubernetes admission controller, via Gatekeeper, OPA's Kubernetes-native integration, which intercepts kubectl apply requests before they reach etcd.

3.2 Rego Fundamentals

Rego policies are organized into packages and express rules as logical statements over input data. A minimal policy that denies public S3 buckets:

package terraform.security

import future.keywords.in

deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_acl"
resource.change.after.acl == "public-read"
msg := sprintf("S3 bucket %v must not have public-read ACL", [resource.address])
}

Reading this rule: for every element in input.resource_changes (a Terraform plan's JSON representation of proposed changes), if the resource type is an S3 bucket ACL and the proposed ACL is public-read, produce a deny message referencing the specific resource address. Rego's evaluation model is declarative — you're not writing an imperative "if/then," you're defining a set of conditions under which deny contains a message, and any non-empty deny set fails the policy check.

3.3 A More Complete Policy: Tagging Enforcement

A common real-world policy is requiring cost-allocation tags on every taggable resource:

package terraform.tagging

import future.keywords.in

required_tags := {"owner", "environment", "cost-center"}

taggable_types := {
"aws_instance",
"aws_s3_bucket",
"aws_db_instance",
"aws_lambda_function",
}

deny[msg] {
resource := input.resource_changes[_]
resource.type in taggable_types
resource.change.actions[_] in {"create", "update"}
provided_tags := object.keys(object.get(resource.change.after, "tags", {}))
missing := required_tags - {tag | tag := provided_tags[_]}
count(missing) > 0
msg := sprintf(
"%v is missing required tags: %v",
[resource.address, concat(", ", missing)],
)
}

This policy computes the set difference between required tags and provided tags, and only fires when that difference is non-empty — meaning a resource with all required tags present produces no denial, regardless of what other tags it happens to have. Notice the policy is permissive by default on anything not explicitly listed in taggable_types — an important design choice covered further in Section 6.

3.4 Testing Rego Policies

Untested policy code is exactly as risky as untested application code — a subtly wrong Rego rule can either fail open (letting through what it should block) or fail closed (blocking legitimate changes), and both failure modes erode trust in the platform. OPA ships a native test framework:

package terraform.tagging_test

import data.terraform.tagging.deny

test_denies_missing_tags {
count(deny) > 0 with input as {
"resource_changes": [{
"address": "aws_instance.web",
"type": "aws_instance",
"change": {
"actions": ["create"],
"after": {"tags": {"owner": "team-a"}},
},
}],
}
}

test_allows_complete_tags {
count(deny) == 0 with input as {
"resource_changes": [{
"address": "aws_instance.web",
"type": "aws_instance",
"change": {
"actions": ["create"],
"after": {
"tags": {
"owner": "team-a",
"environment": "production",
"cost-center": "eng-42",
},
},
},
}],
}
}
$ opa test policies/ -v
data.terraform.tagging_test.test_denies_missing_tags: PASS (1.2ms)
data.terraform.tagging_test.test_allows_complete_tags: PASS (0.9ms)

PASS: 2/2

Treat this test suite exactly like application test code: it lives in version control next to the policies, runs in CI on every policy change, and is a required check before a policy update can merge — since a broken policy has blast radius across every team whose changes flow through it.

3.5 Running OPA Against Terraform Plans with Conftest

Conftest is a thin wrapper around OPA purpose-built for testing structured configuration files (Terraform plan JSON, Kubernetes manifests, Dockerfiles) against Rego policies:

# Generate a plan and convert it to JSON
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json

# Evaluate it against the policy directory
conftest test tfplan.json --policy policies/
FAIL - tfplan.json - terraform.tagging - aws_instance.web is missing required tags: cost-center

2 tests, 1 passed, 0 warnings, 1 failure

This is the pattern from the earlier OpenTofu/Terraform and IDP handbooks in this series — a CI job that fails the build with a specific, actionable message rather than a human catching the same issue two days later in a manual review.

3.6 Gatekeeper: OPA as a Kubernetes Admission Controller

For enforcement at the cluster level — not just in CI, but blocking non-compliant manifests from ever being applied — Gatekeeper wraps OPA as a Kubernetes admission webhook, using ConstraintTemplate and Constraint custom resources:

# constrainttemplate.yaml — defines the reusable policy logic
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
 name: k8srequiredlabels
spec:
 crd:
   spec:
     names:
       kind: K8sRequiredLabels
     validation:
       openAPIV3Schema:
         type: object
         properties:
           labels:
             type: array
             items:
               type: string
 targets:
   - target: admission.k8s.gatekeeper.sh
     rego: |
       package k8srequiredlabels

       violation[{"msg": msg}] {
         required := input.parameters.labels
         provided := input.review.object.metadata.labels
         missing := required[_]
         not provided[missing]
         msg := sprintf("missing required label: %v", [missing])
       }
# constraint.yaml — applies the template with specific parameters, scoped to a namespace
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
 name: require-owner-label
spec:
 match:
   kinds:
     - apiGroups: [""]
       kinds: ["Pod"]
   namespaces: ["production"]
 parameters:
   labels: ["owner", "team"]

Once applied, any attempt to create a Pod in the production namespace without an owner and team label is rejected at admission time, before the object is ever persisted:

$ kubectl apply -f pod.yaml
Error from server ([denied by require-owner-label] missing required label: owner): error when creating "pod.yaml"

This split — ConstraintTemplate as reusable logic, Constraint as scoped parameters — lets a platform team define a policy once and apply it with different parameters or scope (different namespaces, different label requirements) without duplicating Rego code.

4. Kyverno: Kubernetes-Native Policy in YAML

4.1 What Kyverno Is and Why It Exists Alongside OPA

Kyverno was built specifically for Kubernetes, and its core design decision is that policies are expressed as native Kubernetes resources in YAML — no separate query language to learn. For teams whose policy needs are entirely Kubernetes-scoped (as opposed to also covering Terraform plans or arbitrary API payloads), this lower barrier to entry is Kyverno's main advantage over OPA/Gatekeeper: a platform engineer who already knows Kubernetes YAML can read, write, and review Kyverno policies without learning Rego.

Kyverno policies support three core actions natively: validate (allow or deny based on a rule), mutate (rewrite a resource before it's persisted), and generate (create additional resources in response to another resource being created) — the latter two have no direct OPA/Gatekeeper equivalent without additional tooling, and are a meaningful part of Kyverno's appeal.

4.2 A Validating Policy

The equivalent of the Gatekeeper label-requirement example, expressed in Kyverno:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
 name: require-labels
spec:
 validationFailureAction: Enforce
 background: true
 rules:
   - name: check-for-labels
     match:
       any:
         - resources:
             kinds: ["Pod"]
             namespaces: ["production"]
     validate:
       message: "The labels 'owner' and 'team' are required."
       pattern:
         metadata:
           labels:
             owner: "?*"
             team: "?*"

The pattern block reads almost like an example of a compliant resource, with ?* meaning "any non-empty value" — this pattern-matching style is deliberately closer to describing "what good looks like" than writing imperative validation logic, which is a large part of why Kyverno policies tend to be quicker to read and write for straightforward rules.

4.3 A Security-Focused Policy: Disallow Privileged Containers

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
 name: disallow-privileged-containers
spec:
 validationFailureAction: Enforce
 rules:
   - name: privileged-containers
     match:
       any:
         - resources:
             kinds: ["Pod"]
     validate:
       message: "Privileged containers are not allowed."
       pattern:
         spec:
           containers:
             - =(securityContext):
                 =(privileged): "false"

The =() syntax marks a field as conditional — the rule only evaluates privileged if securityContext is present at all, avoiding false positives against Pods that don't set a securityContext block explicitly but also aren't requesting privileged mode.

4.4 Mutation: Auto-Injecting Defaults

Where Gatekeeper can only allow or deny, Kyverno can also silently correct non-compliant-but-fixable resources before they're persisted — useful for defaults developers shouldn't need to remember to set themselves:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
 name: add-default-resources
spec:
 rules:
   - name: add-resource-limits
     match:
       any:
         - resources:
             kinds: ["Pod"]
     mutate:
       patchStrategicMerge:
         spec:
           containers:
             - (name): "*"
               resources:
                 limits:
                   +(memory): "512Mi"
                   +(cpu): "500m"

The +() prefix means "add this field only if it's not already set" — a Pod spec that already declares its own memory limit is left untouched, while one that omits it gets a sane platform default injected automatically, with no developer action required and no build failure to interrupt them.

4.5 Generation: Creating Companion Resources Automatically

Kyverno's generate rules can create a resource automatically whenever a triggering resource is created — a common pattern is auto-creating a default NetworkPolicy whenever a new namespace is provisioned:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
 name: generate-default-networkpolicy
spec:
 rules:
   - name: default-deny-networkpolicy
     match:
       any:
         - resources:
             kinds: ["Namespace"]
     generate:
       apiVersion: networking.k8s.io/v1
       kind: NetworkPolicy
       name: default-deny-all
       namespace: "{{request.object.metadata.name}}"
       synchronize: true
       data:
         spec:
           podSelector: {}
           policyTypes: ["Ingress", "Egress"]

Every new namespace gets a default-deny network policy the moment it's created, without a platform engineer or developer needing to remember to add one — the secure default is structurally guaranteed rather than dependent on someone following a checklist.

4.6 Testing Kyverno Policies

Kyverno ships its own CLI test framework, following a similar philosophy to OPA's:

# test.yaml
name: require-labels-test
policies:
 - require-labels.yaml
resources:
 - compliant-pod.yaml
 - noncompliant-pod.yaml
results:
 - policy: require-labels
   rule: check-for-labels
   resource: compliant-pod
   kind: Pod
   result: pass
 - policy: require-labels
   rule: check-for-labels
   resource: noncompliant-pod
   kind: Pod
   result: fail
$ kyverno test .
Executing require-labels-test...
 Checking policy require-labels...
   ✓ pass: compliant-pod
   ✓ fail: noncompliant-pod

Test Summary: 2 tests passed, 0 failed

5. Choosing Between OPA and Kyverno

Neither tool is strictly superior — they optimize for different scopes and teams:

Choose OPA/Gatekeeper when:

  • Policies need to span multiple systems beyond Kubernetes — Terraform plans, CI pipeline configs, custom API authorization decisions — since Rego and OPA's evaluation model are general-purpose.
  • Your platform or security team already has engineers comfortable writing and testing Rego, or is willing to invest in that skill.
  • You need OPA's decision-log and query API for auditing arbitrary authorization decisions outside Kubernetes.

Choose Kyverno when:

  • Policy scope is entirely Kubernetes-native, and lowering the barrier to entry for the platform team (and any other engineers who need to read or contribute policies) matters more than cross-system generality.
  • You need mutation or generation, not just validation — auto-injecting defaults or auto-creating companion resources has no clean OPA/Gatekeeper equivalent.
  • Your team would rather review a YAML pattern than a Rego expression during a policy pull request.

A common pattern in practice: use OPA/Conftest in CI/CD pipelines to validate Terraform plans and other non-Kubernetes artifacts, and use Kyverno as the Kubernetes admission controller for cluster-native policy — leaning on each tool's comparative strength rather than picking one exclusively for the whole stack. Running both is not unusual and doesn't imply either tool was the "wrong" choice.

5.1 A Side-by-Side Feature Comparison

DimensionOPA / GatekeeperKyvernoPolicy languageRego (general-purpose query language)Native Kubernetes YAMLScopeAny JSON-serializable input — Terraform, Kubernetes, custom APIsKubernetes-native onlyValidateYesYesMutate (auto-fix)Limited, via separate toolingNative, first-classGenerate companion resourcesNot nativeNative, first-classLearning curve for Kubernetes-only teamsSteeper — requires learning RegoLower — YAML patterns familiar to most K8s usersCross-system reuse (same policy engine for IaC and runtime)StrongWeakNative CLI test frameworkYes (opa test)Yes (kyverno test)Typical adoption contextPlatform teams already invested in OPA elsewhere, or needing Terraform + Kubernetes coverage from one engineKubernetes-first teams wanting the fastest path to writing their first policy

5.2 Migration Considerations Between the Two

Teams sometimes start with one tool and later add or switch to the other as their needs grow — typically starting with Kyverno for its lower barrier to entry on cluster-native rules, then adding OPA/Conftest once Terraform or non-Kubernetes policy needs emerge, rather than trying to force Rego to solve a problem Kyverno's YAML already solves more simply. Moving in the other direction — starting with OPA/Gatekeeper and later adopting Kyverno for its mutate/generate capabilities — is equally common among teams that found themselves reaching for a third-party mutating webhook to fill a gap Gatekeeper alone doesn't cover. Running both concurrently, scoped to what each does best, is the steady-state outcome for most platform teams past a certain size, and isn't a sign of an unfinished decision.

6. Guardrails That Don't Slow Teams Down

This section is the difference between a policy-as-code program developers tolerate and one they actively resist and route around. Getting the mechanics of OPA or Kyverno right is necessary but not sufficient — how the guardrails are designed and rolled out determines their actual, lasting effectiveness.

6.1 Warn Before You Block

Never ship a new policy directly in enforcing mode. Both tools support a non-blocking mode — OPA/Conftest can be run with a --output format that reports without failing the build, and Kyverno has a native validationFailureAction: Audit setting:

spec:
 validationFailureAction: Audit  # logs violations without blocking

Run every new policy in audit/warn mode for a defined burn-in period — commonly 1-2 weeks — and review what it would have blocked before flipping it to enforcing. This surfaces false positives and legitimate-but-unanticipated use cases before they become a blocked deploy at 5pm on a Friday, and it gives the platform team real data on a policy's actual impact rather than a guess.

6.2 Error Messages Are a Developer Experience Surface

A denial message like "denied by policy" sends a developer straight to the platform team's Slack channel. A denial message like the ones in Sections 3.2–3.4 and 4.2 — naming the specific resource, the specific missing requirement, and implicitly the fix — resolves the majority of violations without a human ever getting involved. Treat writing a good msg/message field with the same care as writing a good error message in application code; it is, functionally, exactly that.

6.3 Fail Open vs. Fail Closed, Deliberately

Every policy has an implicit answer to "what happens to inputs this policy doesn't recognize?" The tagging policy in Section 3.3 only evaluates resources in an explicit taggable_types set — anything not in that set passes silently. This is usually the right default for early-stage policy rollout (avoiding false positives on resource types nobody has thought about yet), but it means coverage gaps are silent by default. Track policy coverage explicitly — what fraction of actual resource types in your environment are covered by at least one policy — rather than assuming "no violations" means "fully compliant."

6.4 Provide an Escape Hatch, With Accountability Attached

Golden paths and policies are opinionated defaults, not absolute mandates — there are always legitimate exceptions (a load test that genuinely needs a larger instance than policy allows; a legacy system that can't yet meet a new tagging standard). An escape hatch that requires zero accountability (a magic annotation anyone can add) undermines the policy entirely; one that requires so much process it's effectively unusable pushes teams to quietly work around the platform instead. A workable middle ground: an explicit, auditable exception mechanism.

# Kyverno exclusion via annotation, requiring an approved exception ticket reference
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
 name: disallow-privileged-containers
spec:
 rules:
   - name: privileged-containers
     match:
       any:
         - resources:
             kinds: ["Pod"]
             selector:
               matchExpressions:
                 - key: policy.company.io/exception-ticket
                   operator: DoesNotExist
     validate:
       message: "Privileged containers are not allowed without an approved exception (see policy.company.io/exception-ticket)."
       pattern:
         spec:
           containers:
             - =(securityContext):
                 =(privileged): "false"

Requiring the annotation to reference a real, reviewed exception ticket means every deviation from the guardrail is discoverable and auditable later, rather than being an invisible, undocumented workaround.

6.5 Version and Test Policies Like Production Code

Policies deserve the same rigor as the systems they govern: version control, a required review process, automated tests (Sections 3.4 and 4.6), and a staged rollout across environments rather than a single global flip. A policy change that breaks CI for every team simultaneously is itself an incident, and should be treated with the same care as a bad application deploy — including a fast, well-understood rollback path.

# A minimal CI pipeline for the policy repository itself
opa test policies/ -v          # unit tests for Rego logic
conftest verify --policy policies/  # validates policy syntax
kyverno test kyverno-policies/  # unit tests for Kyverno rules

6.6 Measure Policy Health, Not Just Policy Existence

Track the same category of leading indicators a platform team tracks for golden path adoption:

  • Time-to-fix after a policy denial — if developers are stuck for hours after a clear denial message, the message or the fix path needs work, not necessarily the policy itself.
  • Exception rate per policy — a policy with a high, growing exception rate is a signal the default itself may be wrong, not that teams are being careless.
  • False positive reports — a lightweight feedback channel (the same pattern recommended for golden paths) where developers can flag a policy that blocked something legitimate, reviewed on a regular cadence rather than left to accumulate.
  • Coverage — the percentage of resource types, namespaces, or workspaces actually covered by at least one policy, tracked explicitly rather than inferred from an absence of complaints.

6.7 Avoid Policy Sprawl

As a policy-as-code program matures, the number of individual rules tends to grow past what any one person can hold in their head, especially across multiple teams contributing policies independently. Organize policies into clearly scoped categories (security, cost, tagging, reliability) with clear ownership per category, and periodically audit for redundant or contradictory rules — two policies that both technically pass but disagree about what "correctly tagged" means are a maintenance trap waiting to surface as a confusing, hard-to-debug denial.

7. A Practical Rollout Sequence

For a platform team introducing policy-as-code for the first time, a reasonable sequencing:

  1. Pick one high-value, low-controversy policy first — commonly mandatory resource tagging, since it's rarely contested and its value (accurate cost attribution) is easy to explain. 1-2 weeks to write, test, and stage in audit mode.
  2. Run it in audit/warn mode for 1-2 weeks, reviewing what it would have blocked, refining the policy and its error messages based on real violations rather than hypothetical ones.
  3. Flip to enforcing mode for one team or one environment first (typically staging, or a single volunteer team), not the whole org at once.
  4. Expand to the next policy (commonly, blocking public storage buckets or requiring encryption at rest) once the first is stable and trusted, following the same audit-then-enforce sequence.
  5. Introduce the exception mechanism (Section 6.4) before — not after — the first time a legitimate exception is needed, so there's already a known, unsurprising process rather than an ad hoc scramble.
  6. Layer in policy testing and CI for the policy repository itself (Sections 3.4, 4.6, 6.5) as the policy set grows past a handful of rules, treating the policies as a codebase with its own quality bar.
  7. Instrument coverage and exception-rate metrics (Section 6.6) and review them on a regular cadence — monthly is reasonable for most organizations — to catch policy sprawl or drift before it becomes unmanageable.

8. Worked Example: Rolling Out a Tagging Policy End to End

The sections above describe policy-as-code mechanics individually; this section follows one policy — mandatory cost-allocation tagging — through its full lifecycle, from first draft to steady-state enforcement, to show how the pieces connect in practice.

8.1 Week 1 — Drafting and Testing

Maria, a platform engineer, is asked to stop the recurring problem of untagged AWS resources showing up in the monthly cost report with no owning team. She starts from the Rego policy in Section 3.3, adds the test cases from Section 3.4, and runs them locally before anything touches a real pipeline:

$ opa test policies/ -v
data.terraform.tagging_test.test_denies_missing_tags: PASS (1.1ms)
data.terraform.tagging_test.test_allows_complete_tags: PASS (0.8ms)

PASS: 2/2

She wires the policy into the shared Terraform CI pipeline, but in audit mode — implemented here as a Conftest run whose exit code is intentionally ignored so it reports without blocking:

# .github/workflows/terraform-ci.yaml (excerpt)
- name: Policy check (audit mode)
 run: |
   terraform show -json tfplan > tfplan.json
   conftest test tfplan.json --policy policies/ --output json > policy-report.json || true
   cat policy-report.json

8.2 Week 2 — Reviewing Real Violations

Over the following two weeks, the audit-mode job runs on every Terraform plan across the org without blocking anything. Maria reviews the accumulated policy-report.json output and finds two categories of real violation:

{
 "filename": "tfplan.json",
 "namespace": "terraform.tagging",
 "failures": [
   {"msg": "aws_lambda_function.image_resizer is missing required tags: cost-center"},
   {"msg": "aws_instance.legacy_batch_job is missing required tags: owner, cost-center"}
 ]
}

The first is a simple oversight — the team just needs to add the tag. The second, legacy_batch_job, turns out to be a system nobody has clearly owned since a reorg eighteen months earlier — exactly the kind of gap the policy was designed to surface, and exactly the kind of thing that would have gone undetected indefinitely without this check.

8.3 Week 3 — Enforcing, With an Exception Path Ready

Before flipping the policy to blocking mode, Maria adds the exception mechanism from Section 6.4, adapted for Terraform via a variable-driven override rather than a Kubernetes annotation:

package terraform.tagging

exempt_resources := {r | r := input.resource_changes[_]; r.address == input.exceptions[_]}

deny[msg] {
resource := input.resource_changes[_]
not resource in exempt_resources
resource.type in taggable_types
resource.change.actions[_] in {"create", "update"}
provided_tags := object.keys(object.get(resource.change.after, "tags", {}))
missing := required_tags - {tag | tag := provided_tags[_]}
count(missing) > 0
msg := sprintf("%v is missing required tags: %v", [resource.address, concat(", ", missing)])
}
# Exceptions are supplied explicitly and reviewed in the pull request that adds them
conftest test tfplan.json --policy policies/ --data exceptions.json
// exceptions.json — reviewed and merged like any other change, so it's auditable
{
 "exceptions": ["aws_instance.legacy_batch_job"]
}

With the exception path in place and reviewed, she flips validationFailureAction-equivalent behavior to blocking for the Growth team first — the team that originally reported the cost-attribution problem and is motivated to see it enforced — by making the CI step's exit code authoritative:

- name: Policy check (enforcing)
 run: |
   terraform show -json tfplan > tfplan.json
   conftest test tfplan.json --policy policies/ --data exceptions.json
   # no `|| true` here — a failure now fails the build

8.4 Month 2 — Org-Wide Enforcement and Measurement

After two weeks of clean enforcement for the pilot team with no unexpected blocks, Maria rolls the same enforcing configuration out org-wide. She adds a lightweight dashboard tracking the metrics from Section 6.6:

{
 "policy": "terraform.tagging",
 "month": "2026-08",
 "violations_caught_in_ci": 34,
 "exceptions_granted": 2,
 "mean_time_to_fix_minutes": 6,
 "false_positive_reports": 0
}

A mean time-to-fix of six minutes — the time it takes a developer to read the denial message, add the missing tag, and re-run the plan — is the concrete evidence that the guardrail is doing its job without becoming a bottleneck. Two exceptions, both reviewed and merged deliberately, is a healthy number: enough to show the escape hatch works, not so many that it suggests the policy itself is miscalibrated.

What's notable across this rollout is the same pattern described abstractly in Section 6: audit before enforce, a pilot team before org-wide rollout, a reviewed exception path established before it was needed, and concrete metrics collected from day one rather than relying on the absence of complaints as a proxy for success.

9. Conclusion

Policy-as-code succeeds when it's judged the way any other piece of developer-facing infrastructure should be judged: by how invisible it is on the happy path, and how clear and actionable it is on the unhappy one. OPA and Kyverno solve overlapping but distinct problems — OPA's generality suits cross-system enforcement from Terraform plans to arbitrary API decisions, while Kyverno's native Kubernetes YAML lowers the barrier for teams whose policy needs stay inside the cluster — and many mature platforms end up running both, each where its strengths matter most.

The technology, though, is the easier half. The harder half is organizational discipline: rolling out new policies in audit mode before enforcing them, writing denial messages that resolve issues without a human in the loop, giving legitimate exceptions an auditable path instead of an invisible one, and treating policies as versioned, tested code rather than a pile of YAML that accretes indefinitely. Done this way, guardrails stop being something teams route around and become something they mostly never notice — which is exactly the point.

Appendix A: Glossary

  • Policy-as-code — Expressing compliance, security, or operational rules as machine-evaluated code that runs automatically in a pipeline, rather than via manual review.
  • Rego — The declarative query language used by Open Policy Agent to express policies over structured data.
  • Gatekeeper — OPA's Kubernetes-native integration, enforcing policies as an admission webhook via ConstraintTemplate and Constraint resources.
  • Kyverno — A Kubernetes-native policy engine expressing validate, mutate, and generate rules as plain YAML ClusterPolicy/Policy resources.
  • Conftest — A CLI wrapper around OPA for testing structured configuration files (Terraform plan JSON, Kubernetes manifests) against Rego policies.
  • Admission controller — A Kubernetes component that intercepts API requests before they're persisted, used by both Gatekeeper and Kyverno to enforce policy at the moment of kubectl apply.
  • Audit/warn mode — A non-blocking policy evaluation mode that reports violations without failing the request, used for safely rolling out new policies.
  • Shift-left — The principle of moving a check as early as possible in a pipeline, since the cost of fixing a violation grows the later it's caught.

Appendix B: Policy Rollout Checklist

  • [ ]  Policy has an automated test suite covering both pass and fail cases
  • [ ]  Policy has been run in audit/warn mode for a defined burn-in period
  • [ ]  Denial messages name the specific resource and the specific violated requirement
  • [ ]  An auditable exception mechanism exists before the policy goes to enforcing mode
  • [ ]  Policy is scoped to a clearly owned category with a named owner
  • [ ]  Coverage (which resource types/namespaces are actually checked) is documented, not assumed
  • [ ]  Rollout proceeds environment-by-environment or team-by-team, not org-wide on day one
  • [ ]  A rollback path exists if the policy causes unexpected, widespread denials