|
| 1 | +--- |
| 2 | +title: Plan Validation (OPA) |
| 3 | +description: |
| 4 | + Learn how to use OPA/Rego plan validation rules to inspect a deployment plan |
| 5 | + before you roll it out and surface policy violations as a preview. |
| 6 | +--- |
| 7 | + |
| 8 | +**Plan validation rules** run [OPA](https://www.openpolicyagent.org/) (Open |
| 9 | +Policy Agent) policies against a deployment **plan** — the diff between what is |
| 10 | +currently deployed and what a new version proposes — and report any violations |
| 11 | +back to you. |
| 12 | + |
| 13 | +<Note> |
| 14 | + Plan validation is **non-blocking**. It does **not** gate or stop a |
| 15 | + deployment. It exists to give you a preview: when a new version is planned, |
| 16 | + you can see whether the proposed changes pass your policies before you decide |
| 17 | + to roll them out. Violations are surfaced on the deployment's GitHub check and |
| 18 | + in the ctrlplane UI, but the deployment is never automatically held back. |
| 19 | +</Note> |
| 20 | + |
| 21 | +## Overview |
| 22 | + |
| 23 | +```mermaid |
| 24 | +flowchart TD |
| 25 | + A[Version Planned] --> B[Agent computes plan<br/>current vs. proposed] |
| 26 | + B --> C{Policy selector<br/>matches target?} |
| 27 | + C -->|No| D[No validation run] |
| 28 | + C -->|Yes| E[Evaluate Rego deny rules] |
| 29 | + E --> F{Any denials?} |
| 30 | + F -->|No| G[Reported as passed] |
| 31 | + F -->|Yes| H[Reported as violations] |
| 32 | +``` |
| 33 | + |
| 34 | +A plan validation rule is a normal **policy rule**, so it shares the policy's |
| 35 | +CEL `selector` that decides which release targets it applies to. The difference |
| 36 | +is that instead of approvals or windows, the rule carries a snippet of **Rego** |
| 37 | +that inspects the computed plan. |
| 38 | + |
| 39 | +## When does it run? |
| 40 | + |
| 41 | +Validation runs after an agent finishes computing a plan. Only agents that |
| 42 | +produce a plan are evaluated: |
| 43 | + |
| 44 | +- **ArgoCD** — the rendered manifest diff |
| 45 | +- **Terraform Cloud** — the speculative plan output |
| 46 | + |
| 47 | +If a deployment uses an agent that does not support plan operations, no |
| 48 | +validation is run for that target. |
| 49 | + |
| 50 | +## Why use plan validation? |
| 51 | + |
| 52 | +- **Preview before you ship** — confirm a proposed change looks right before |
| 53 | + promoting it. |
| 54 | +- **Catch risky diffs** — flag deletions, replacements, or scaling changes in a |
| 55 | + Terraform plan. |
| 56 | +- **Guard against secrets or forbidden values** in rendered manifests. |
| 57 | +- **Codify review checklists** that your team would otherwise eyeball by hand. |
| 58 | + |
| 59 | +## Configuration |
| 60 | + |
| 61 | +<Tabs> |
| 62 | +<Tab title="Terraform"> |
| 63 | +```hcl |
| 64 | +resource "ctrlplane_policy" "no_destroy_in_prod" { |
| 65 | + name = "No destructive changes in production" |
| 66 | + selector = "environment.name == 'production'" |
| 67 | +
|
| 68 | + plan_validation_opa { |
| 69 | + name = "no-resource-deletions" |
| 70 | + description = "Flag plans that delete resources" |
| 71 | + rego = <<-EOT |
| 72 | + package ctrlplane |
| 73 | +
|
| 74 | + import rego.v1 |
| 75 | +
|
| 76 | + deny contains msg if { |
| 77 | + input.agentType == "terraform-cloud" |
| 78 | + plan := json.unmarshal(input.proposed) |
| 79 | + change := plan.resource_changes[_] |
| 80 | + change.change.actions[_] == "delete" |
| 81 | + msg := sprintf("resource %q would be deleted", [change.address]) |
| 82 | + } |
| 83 | + EOT |
| 84 | + } |
| 85 | +} |
| 86 | +``` |
| 87 | +</Tab> |
| 88 | +<Tab title="API"> |
| 89 | +```bash |
| 90 | +curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \ |
| 91 | + -H "Authorization: Bearer $TOKEN" \ |
| 92 | + -H "Content-Type: application/json" \ |
| 93 | + -d '{ |
| 94 | + "name": "No destructive changes in production", |
| 95 | + "selector": "environment.name == '\''production'\''", |
| 96 | + "rules": [ |
| 97 | + { |
| 98 | + "planValidationOpa": { |
| 99 | + "name": "no-resource-deletions", |
| 100 | + "description": "Flag plans that delete resources", |
| 101 | + "rego": "package ctrlplane\n\nimport rego.v1\n\ndeny contains msg if {\n input.agentType == \"terraform-cloud\"\n plan := json.unmarshal(input.proposed)\n change := plan.resource_changes[_]\n change.change.actions[_] == \"delete\"\n msg := sprintf(\"resource %q would be deleted\", [change.address])\n}" |
| 102 | + } |
| 103 | + } |
| 104 | + ] |
| 105 | + }' |
| 106 | +``` |
| 107 | +</Tab> |
| 108 | +</Tabs> |
| 109 | + |
| 110 | +## Properties |
| 111 | + |
| 112 | +<ParamField path="planValidationOpa.name" type="string" required> |
| 113 | + Human-readable rule name. Shown in the check output to identify which rule |
| 114 | + produced a violation. |
| 115 | +</ParamField> |
| 116 | + |
| 117 | +<ParamField path="planValidationOpa.description" type="string"> |
| 118 | + Optional human-readable explanation of what the rule does. |
| 119 | +</ParamField> |
| 120 | + |
| 121 | +<ParamField path="planValidationOpa.rego" type="string" required> |
| 122 | + Rego v1 source code. Must define a `deny` rule set following the |
| 123 | + [Conftest](https://www.conftest.dev/) convention |
| 124 | + (`deny contains msg if { ... }`). Each member of the `deny` set becomes one |
| 125 | + violation message. An empty `deny` set means the plan passed. |
| 126 | +</ParamField> |
| 127 | + |
| 128 | +## Writing the Rego |
| 129 | + |
| 130 | +Policies must be **Rego v1** (include `import rego.v1`) and define a `deny` rule |
| 131 | +set. The package name can be anything — ctrlplane detects it automatically. |
| 132 | + |
| 133 | +```rego |
| 134 | +package ctrlplane |
| 135 | +
|
| 136 | +import rego.v1 |
| 137 | +
|
| 138 | +deny contains msg if { |
| 139 | + input.environment.name == "production" |
| 140 | + msg := "changes to production require manual review" |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +- The set is **empty by default** → the plan passes. |
| 145 | +- Every string you add to `deny` becomes a separate violation line. |
| 146 | +- Use the OPA standard library (`json.unmarshal`, `yaml.unmarshal`, `contains`, |
| 147 | + `sprintf`, regex, etc.) to parse and inspect the plan. |
| 148 | + |
| 149 | +<Warning> |
| 150 | + If a policy contains invalid Rego (for example, a syntax error), it cannot be |
| 151 | + evaluated and no validation result is recorded for that plan. Test your |
| 152 | + policies with the [OPA playground](https://play.openpolicyagent.org/) or |
| 153 | + `opa eval` before saving them. |
| 154 | +</Warning> |
| 155 | + |
| 156 | +## The `input` document |
| 157 | + |
| 158 | +Your Rego policy is evaluated against an `input` document with the following |
| 159 | +fields: |
| 160 | + |
| 161 | +| Field | Type | Description | |
| 162 | +| ---------------------- | ------- | ------------------------------------------------------------------------ | |
| 163 | +| `input.current` | string | The currently deployed state (raw, agent-specific format) | |
| 164 | +| `input.proposed` | string | The proposed state from the new version (raw, agent-specific format) | |
| 165 | +| `input.hasChanges` | boolean | Whether the plan contains any changes | |
| 166 | +| `input.agentType` | string | The agent that produced the plan (e.g. `argo-cd`, `terraform-cloud`) | |
| 167 | +| `input.environment` | object | The target environment | |
| 168 | +| `input.resource` | object | The target resource | |
| 169 | +| `input.deployment` | object | The deployment | |
| 170 | +| `input.proposedVersion`| object | The deployment version being planned (the new version) | |
| 171 | +| `input.currentVersion` | object | The version currently deployed to this target (may be null) | |
| 172 | + |
| 173 | +<Note> |
| 174 | + `input.current` and `input.proposed` are **strings**, not parsed objects, |
| 175 | + because the format is agent-specific (Terraform JSON plan, rendered Kubernetes |
| 176 | + YAML, etc.). Parse them inside your policy with `json.unmarshal` or |
| 177 | + `yaml.unmarshal`. |
| 178 | +</Note> |
| 179 | + |
| 180 | +## Examples |
| 181 | + |
| 182 | +### Flag resource deletions in a Terraform plan |
| 183 | + |
| 184 | +```rego |
| 185 | +package ctrlplane |
| 186 | +
|
| 187 | +import rego.v1 |
| 188 | +
|
| 189 | +deny contains msg if { |
| 190 | + input.agentType == "terraform-cloud" |
| 191 | + plan := json.unmarshal(input.proposed) |
| 192 | + change := plan.resource_changes[_] |
| 193 | + change.change.actions[_] == "delete" |
| 194 | + msg := sprintf("resource %q would be deleted", [change.address]) |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +### Forbid hard-coded secrets in a rendered manifest |
| 199 | + |
| 200 | +```rego |
| 201 | +package ctrlplane |
| 202 | +
|
| 203 | +import rego.v1 |
| 204 | +
|
| 205 | +deny contains msg if { |
| 206 | + contains(input.proposed, "SECRET=") |
| 207 | + msg := "proposed manifest appears to contain a hard-coded secret" |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +### Require that a production change actually has a diff |
| 212 | + |
| 213 | +```rego |
| 214 | +package ctrlplane |
| 215 | +
|
| 216 | +import rego.v1 |
| 217 | +
|
| 218 | +deny contains "no changes detected for a production deploy" if { |
| 219 | + input.environment.name == "production" |
| 220 | + not input.hasChanges |
| 221 | +} |
| 222 | +``` |
| 223 | + |
| 224 | +### Flag a major version jump |
| 225 | + |
| 226 | +```rego |
| 227 | +package ctrlplane |
| 228 | +
|
| 229 | +import rego.v1 |
| 230 | +
|
| 231 | +deny contains msg if { |
| 232 | + current := input.currentVersion.tag |
| 233 | + proposed := input.proposedVersion.tag |
| 234 | + startswith(current, "v1.") |
| 235 | + startswith(proposed, "v2.") |
| 236 | + msg := sprintf("major version jump %s -> %s", [current, proposed]) |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +## Viewing results |
| 241 | + |
| 242 | +When a plan completes, ctrlplane records each rule's result against the plan. |
| 243 | +Results surface in two places: |
| 244 | + |
| 245 | +- **GitHub check run** — if the version carries GitHub metadata, the deployment |
| 246 | + check shows the plan diff and a **Policy violations** section listing each |
| 247 | + failing rule and its messages. A violation marks the check as failed so it is |
| 248 | + visible on the pull request, but it does not block the ctrlplane deployment. |
| 249 | +- **ctrlplane UI** — the plan preview for a release target shows the diff |
| 250 | + alongside any validation results. |
| 251 | + |
| 252 | +## Best practices |
| 253 | + |
| 254 | +- ✅ Use a clear `name` — it is how violations are labeled in the output. |
| 255 | +- ✅ Scope rules with the policy `selector` so they only run where they matter |
| 256 | + (e.g. production only). |
| 257 | +- ✅ Branch on `input.agentType` when a policy is specific to Terraform or |
| 258 | + ArgoCD output. |
| 259 | +- ✅ Test Rego in the OPA playground before saving. |
| 260 | +- ❌ Don't rely on plan validation to *stop* a deployment — it is a preview, not |
| 261 | + a gate. Use [Approval](./approval) or |
| 262 | + [Environment Progression](./environment-progression) for gating. |
| 263 | + |
| 264 | +## Next Steps |
| 265 | + |
| 266 | +- [Policies Overview](./overview) - Learn about policy structure |
| 267 | +- [Approval](./approval) - Require sign-off before deploying |
| 268 | +- [Verification](./verification/overview) - Check metrics after deploying |
0 commit comments