|
| 1 | +# Scenario: tf-policy evaluation and override |
| 2 | + |
| 3 | +This scenario covers reading tf-policy compliance results for a run, filtering |
| 4 | +policy-set outcomes, and overriding a `mandatory_overridable` failure. tf-policy |
| 5 | +is HCP Terraform's native policy-as-code engine (distinct from Sentinel and |
| 6 | +OPA) — evaluations are attached to a run's stages (Init/Plan/Apply) rather |
| 7 | +than created directly, so this scenario reads and reacts to results rather |
| 8 | +than authoring them. |
| 9 | + |
| 10 | +> tf-policy is HCP Terraform only and gated behind an organization-level |
| 11 | +> feature flag while in private beta. If policy-set creation with |
| 12 | +> `kind=PolicyKind.TFPOLICY` fails validation, confirm the flag is enabled for |
| 13 | +> your organization before assuming a client-side issue. |
| 14 | +
|
| 15 | +## Prerequisites |
| 16 | + |
| 17 | +```bash |
| 18 | +export TFE_TOKEN="your-api-token" |
| 19 | +export TFE_ADDRESS="https://app.terraform.io" |
| 20 | +``` |
| 21 | + |
| 22 | +The workspace whose runs you're inspecting must be running a Terraform |
| 23 | +version tf-policy supports (`>= 1.16.0-alpha20260626` at the time of writing — |
| 24 | +check with your organization admin, since this is a fast-moving minimum on a |
| 25 | +beta feature). Evaluations on an older version come back `errored` with an |
| 26 | +`incompatible_terraform_version_error`, not a client-side exception. |
| 27 | + |
| 28 | +## Step 1: List a run's tf-policy evaluations |
| 29 | + |
| 30 | +A run has one evaluation per applicable stage, and each stage evaluates a |
| 31 | +different scope (see [Evaluation and enforcement](https://developer.hashicorp.com/terraform/policy#evaluation-and-enforcement)): |
| 32 | + |
| 33 | +- **Init** — provider and module policies, evaluated during workspace |
| 34 | + initialization, before Terraform installs providers or modules. |
| 35 | +- **Plan** — resource policies whose referenced attributes are known at plan |
| 36 | + time, evaluated against the proposed plan before any infrastructure |
| 37 | + changes. |
| 38 | +- **Apply** — resource policies that reference computed values (ARNs, IDs, |
| 39 | + and similar) that stay unknown until Terraform actually applies; these |
| 40 | + necessarily run after infrastructure changes are made. |
| 41 | + |
| 42 | +An Init-stage evaluation only has an empty result if your policy set has no |
| 43 | +provider/module policies — don't assume `list()[0]` is the interesting one |
| 44 | +for a resource-policy scenario; find the evaluation whose stage matches what |
| 45 | +your policy actually targets. |
| 46 | + |
| 47 | +```python |
| 48 | +from pytfe import TFEClient |
| 49 | + |
| 50 | +client = TFEClient() |
| 51 | +run_id = "run-abc123" |
| 52 | + |
| 53 | +evaluations = list(client.tf_policy_evaluations.list(run_id)) |
| 54 | +for e in evaluations: |
| 55 | + print(e.id, e.stage_type, e.status, e.result_count) |
| 56 | +``` |
| 57 | + |
| 58 | +## Step 2: Read one evaluation, with outcomes sideloaded |
| 59 | + |
| 60 | +```python |
| 61 | +from pytfe.models import TfPolicyEvaluationListOptions |
| 62 | + |
| 63 | +opts = TfPolicyEvaluationListOptions(include="tf_policy_set_outcomes") |
| 64 | +evaluation = client.tf_policy_evaluations.read(evaluations[0].id, options=opts) |
| 65 | + |
| 66 | +print(evaluation.status, evaluation.actions, evaluation.permissions) |
| 67 | +``` |
| 68 | + |
| 69 | +`evaluation.actions.is_overridable` and `evaluation.permissions.can_override` |
| 70 | +both need to be `True` before an override call will succeed — check them |
| 71 | +before attempting one rather than relying on the error path. |
| 72 | + |
| 73 | +## Step 3: Inspect policy-set outcomes and diagnostics |
| 74 | + |
| 75 | +```python |
| 76 | +for outcome in client.tf_policy_evaluations.list_set_outcomes(evaluation.id): |
| 77 | + print(outcome.policy_set_name, outcome.result_count) |
| 78 | + for policy in outcome.outcomes: |
| 79 | + print(" ", policy.policy_name, policy.status, policy.enforcement_level) |
| 80 | + for diag in policy.diagnostics: |
| 81 | + print(" diag:", diag.summary, [r.resource_name for r in diag.resources]) |
| 82 | +``` |
| 83 | + |
| 84 | +Filter to just the failures, or just one enforcement level: |
| 85 | + |
| 86 | +```python |
| 87 | +from pytfe.models import TfPolicySetOutcomeListOptions |
| 88 | + |
| 89 | +failed = client.tf_policy_evaluations.list_set_outcomes( |
| 90 | + evaluation.id, |
| 91 | + options=TfPolicySetOutcomeListOptions(filter_status="failed"), |
| 92 | +) |
| 93 | + |
| 94 | +mandatory_overridable = client.tf_policy_evaluations.list_set_outcomes( |
| 95 | + evaluation.id, |
| 96 | + options=TfPolicySetOutcomeListOptions( |
| 97 | + filter_enforcement_level="mandatory_overridable" |
| 98 | + ), |
| 99 | +) |
| 100 | +``` |
| 101 | + |
| 102 | +You can also read a single outcome directly if you already have its ID (e.g. |
| 103 | +from a webhook payload) without listing through the evaluation: |
| 104 | + |
| 105 | +```python |
| 106 | +outcome = client.tf_policy_set_outcomes.read("tfpsout-abc123") |
| 107 | +``` |
| 108 | + |
| 109 | +## Step 4: Override a `mandatory_overridable` failure |
| 110 | + |
| 111 | +```python |
| 112 | +from pytfe.models import TfPolicyEvaluationOverrideOptions |
| 113 | + |
| 114 | +result = client.tf_policy_evaluations.override( |
| 115 | + evaluation.id, |
| 116 | + TfPolicyEvaluationOverrideOptions(comment="Approved by platform-team — ticket OPS-123"), |
| 117 | +) |
| 118 | +print(result.status) # "overridden" |
| 119 | +``` |
| 120 | + |
| 121 | +`comment` is optional — omit `options` entirely to override with no comment. |
| 122 | +The override only succeeds while the evaluation is in `awaiting_override` |
| 123 | +status; calling it again on an already-overridden evaluation raises `TFEError` |
| 124 | +rather than silently no-op'ing, so guard on `status` first if you're looping |
| 125 | +over a batch. |
| 126 | + |
| 127 | +Override is only available for **Plan-stage** evaluations. Init and Apply |
| 128 | +stage evaluations are never overridable, regardless of enforcement level or |
| 129 | +`AWAITING_OVERRIDE` status — check `stage_type == TfPolicyStage.PLAN` (in |
| 130 | +addition to `actions.is_overridable`) before attempting one: |
| 131 | + |
| 132 | +```python |
| 133 | +from pytfe.models import TfPolicyEvaluationStatus, TfPolicyStage |
| 134 | + |
| 135 | +overridable = [ |
| 136 | + e for e in evaluations |
| 137 | + if e.status == TfPolicyEvaluationStatus.AWAITING_OVERRIDE |
| 138 | + and e.stage_type == TfPolicyStage.PLAN |
| 139 | + and e.actions and e.actions.is_overridable |
| 140 | +] |
| 141 | +for e in overridable: |
| 142 | + client.tf_policy_evaluations.override(e.id) |
| 143 | +``` |
| 144 | + |
| 145 | +## Step 5: Gate a downstream workflow on compliance |
| 146 | + |
| 147 | +The read-only surface above is enough to build a pre-flight compliance gate — |
| 148 | +this is the pattern the `hashicorp.terraform` Ansible collection's |
| 149 | +`tf_policy_evaluation_info` module wraps: |
| 150 | + |
| 151 | +```python |
| 152 | +evaluations = list(client.tf_policy_evaluations.list(run_id)) |
| 153 | +non_compliant = [ |
| 154 | + e for e in evaluations |
| 155 | + if e.status in ( |
| 156 | + TfPolicyEvaluationStatus.FAILED, |
| 157 | + TfPolicyEvaluationStatus.ERRORED, |
| 158 | + TfPolicyEvaluationStatus.AWAITING_OVERRIDE, |
| 159 | + ) |
| 160 | +] |
| 161 | +if non_compliant: |
| 162 | + raise SystemExit(f"Run {run_id} is not tf-policy compliant: {non_compliant}") |
| 163 | +``` |
| 164 | + |
| 165 | +## Creating a `kind=tfpolicy` policy set |
| 166 | + |
| 167 | +tf-policy policy sets are primarily supported as **VCS-connected** policy |
| 168 | +sets — commit your `.policy.hcl` files to a repository and connect it via |
| 169 | +`vcs_repo` on `PolicySetCreateOptions`, the same pattern as Sentinel/OPA |
| 170 | +policy sets. That's the supported path for anything beyond local iteration. |
| 171 | + |
| 172 | +For quick local testing without setting up a VCS/OAuth connection, the |
| 173 | +direct-upload path below also works and is what this scenario uses: |
| 174 | + |
| 175 | +```python |
| 176 | +from pytfe.models import PolicyKind, PolicySetCreateOptions |
| 177 | + |
| 178 | +policy_set = client.policy_sets.create( |
| 179 | + "my-organization", |
| 180 | + PolicySetCreateOptions( |
| 181 | + name="tfpolicy-guardrails", |
| 182 | + kind=PolicyKind.TFPOLICY, |
| 183 | + policy_tool_version="0.1.0", |
| 184 | + agent_enabled=True, |
| 185 | + overridable=True, |
| 186 | + ), |
| 187 | +) |
| 188 | + |
| 189 | +version = client.policy_set_versions.create(policy_set.id) |
| 190 | +client.policy_set_versions.upload(version, "./policies") # directory, not a tarball |
| 191 | +``` |
| 192 | + |
| 193 | +`client.policy_set_versions.upload()` takes the `PolicySetVersion` object |
| 194 | +itself (it reads the upload link off it), not a URL string — this differs |
| 195 | +from `client.configuration_versions.upload()`, which does take the upload URL |
| 196 | +directly. Easy to transpose the two if you're working with both in the same |
| 197 | +script. |
| 198 | + |
| 199 | +If your `.policy.hcl` files live in a subdirectory of the uploaded archive |
| 200 | +rather than at its root, set `policies_path` on the policy set to point at |
| 201 | +that subdirectory: |
| 202 | + |
| 203 | +```python |
| 204 | +from pytfe.models import PolicySetUpdateOptions |
| 205 | + |
| 206 | +client.policy_sets.update( |
| 207 | + policy_set.id, |
| 208 | + PolicySetUpdateOptions(policies_path="policies"), |
| 209 | +) |
| 210 | +``` |
| 211 | + |
| 212 | +Without `policies_path` set, the engine looks for policy files at the |
| 213 | +archive root. A nested layout with `policies_path` left unset is accepted |
| 214 | +without error and silently evaluates zero policies — every evaluation |
| 215 | +"passes" with `result_count` at all zeros, indistinguishable from a |
| 216 | +genuinely compliant run until you notice nothing was actually checked. |
| 217 | + |
| 218 | +## Cleanup |
| 219 | + |
| 220 | +```python |
| 221 | +client.policy_sets.delete(policy_set.id) |
| 222 | +``` |
| 223 | + |
| 224 | +Evaluations themselves aren't deletable - they're immutable records tied to |
| 225 | +the run that produced them and are cleaned up when the run/workspace is. |
| 226 | + |
| 227 | +## Wire-format notes |
| 228 | + |
| 229 | +- `TfPolicyEnforcementLevel.MANDATORY_OVERRIDABLE` serializes as |
| 230 | + `"mandatory_overridable"` - underscore, unlike the hyphenated style used |
| 231 | + elsewhere in the JSON:API surface. |
| 232 | +- The `outcomes` array on `TfPolicySetOutcome`, and everything nested inside |
| 233 | + it (`PolicyOutcome`, `Diagnostic`, `OutcomeResource`, `TraversalValue`, |
| 234 | + `PassedResource`), is **snake_case on the wire** rather than dash-cased. |
| 235 | + The backend serializes that column verbatim from a stored value rather than |
| 236 | + passing it through the usual attribute-name transform, so the SDK models |
| 237 | + read it as-is — this is intentional, not a bug, if you're ever comparing |
| 238 | + raw JSON against the rest of the API's dash-case convention. |
| 239 | +- `override()`'s request body is a bare `{"comment": "..."}`, not a JSON:API |
| 240 | + `{"data": {"attributes": {...}}}` envelope - the SDK handles this for you, |
| 241 | + but it's worth knowing if you're debugging against raw HTTP logs. |
0 commit comments