Skip to content

Policy Input

A plan policy reads one JSON document, input. This page describes it: what Zenfra puts in it, how sensitive values are replaced, how a hook adds its own reports, and how to see a real run's input while you write a policy.

The input document

input
├── zenfra
│   ├── input_version        1
│   ├── run                  id, type, status, trigger, trigger_source,
│   │                        triggered_by {id, email}, triggered_at,
│   │                        plan_has_changes,
│   │                        delta {add, change, destroy, imported, moved, forgotten, total}
│   ├── stack                id, name, slug, space_id, iac {engine, version}, labels []
│   ├── commit               sha, ref, pr_number
│   ├── input_status         plan_json, custom_inputs
│   └── sanitizer            kek_id, version
├── terraform                format_version, terraform_version, complete, errored,
│                            applyable, resource_changes[], checks[]
└── third_party_metadata
    ├── custom               {<key>: <report>}
    └── custom_status        {<key>: <status>}

Every path is always present. A value that does not apply is null, an empty collection is {} or [], so a rule's path always resolves. input_version changes only when the document changes in a way that could break a policy.

Run, stack and commit

  • zenfra.run.type is TRACKED or PROPOSED. status is the run's status when it was evaluated.
  • zenfra.run.trigger is manual, vcs, drift or unknown; trigger_source is the detailed source behind it.
  • zenfra.run.triggered_by is the user who started the run; email is an empty string when that user no longer exists. triggered_at is in UTC.
  • zenfra.run.plan_has_changes and delta are the plan's summary: resources to add, change and destroy, and those imported, moved and forgotten.
  • zenfra.commit is the commit the run planned, its ref, and the pull request number for a run started by a pull request.
  • zenfra.input_status.plan_json is always ok: when the plan cannot be read, no input is built and the verdict is Policy not evaluated. custom_inputs is described under Custom inputs.
  • zenfra.sanitizer names the key and the scheme that replaced sensitive values.

zenfra.stack.labels are the stack's labels as they were when the run was created, not as they are now, so editing a label never changes the verdict of an existing run, re-evaluation included. Labels match exactly:

package zenfra

deny contains "production stacks need a team label" if {
    "prod" in input.zenfra.stack.labels
    not has_team_label
}

has_team_label if {
    some l in input.zenfra.stack.labels
    startswith(l, "team-")
}

The Terraform plan

terraform is a selection from the plan's JSON form (terraform show -json or tofu show -json), never the plan verbatim.

  • Each entry of resource_changes keeps address, module_address, mode, type, name, index, provider_name, deposed and change, with actions, before, after, after_unknown, before_sensitive, after_sensitive and replace_paths.
  • checks keeps each check's address and status, without its messages.
  • format_version is the plan format; Zenfra reads format 1.

Sensitive values are replaced before a policy sees them. Wherever Terraform or OpenTofu marks a value in before or after as sensitive, the value becomes a token such as sanitized:v1: followed by 32 hexadecimal characters. The same value gives the same token under the same organization key, so a rule can compare two sensitive values in one input without seeing either. Tokens from different runs match only while your organization's key is unchanged; a re-evaluation uses the key the run was first evaluated with. The sensitivity masks themselves are kept in before_sensitive and after_sensitive.

Custom inputs

A run can hand the output of its own tools, such as a security scanner's report, to its policies. Write the report as a JSON file named <key>.custom.zenfra.json in the stack's project directory during the plan, usually from an After plan hook; see Custom inputs on Run Hooks. The policy reads it as input.third_party_metadata.custom.<key>.

The worker collects the files after the plan and after every After plan hook has succeeded, so a failing hook means no report at all; it collects them only on tracked and proposed runs, and only from the project directory itself, not its subdirectories. Each file must follow these rules:

Rule Otherwise
The key, the file name without .custom.zenfra.json, matches ^[a-z0-9][a-z0-9_-]{0,63}$ invalid_key
The key is not cost, which is reserved reserved
A regular file, not a symbolic link, a directory or a pipe invalid
At most 1 MiB, counted as written and again without whitespace too_large
Exactly one JSON object ({} is fine), at most 32 top-level keys, nested at most 32 deep, no duplicate key at any level, nothing after it invalid
All accepted reports together stay within 5 MiB without whitespace too_large; a later, smaller report may still fit
At most 128 files named *.custom.zenfra.json in the directory none is processed

Two statuses tell a policy what arrived. input.zenfra.input_status.custom_inputs describes the collection as a whole:

Value Meaning custom
absent The worker reported no collection, usually because it predates custom inputs {}
none The directory was scanned and nothing was stored: no files, every file refused, or the upload failed {}
ok The stored reports were read back and every rule was checked again the reports
incomplete The scan did not finish: more than 128 files, or the directory could not be read {}

When the collection completes, input.third_party_metadata.custom_status has one entry per file the worker found, keyed by its key: ok, invalid, too_large, invalid_key, reserved, or upload_failed (the file was accepted, but storing it failed). A refused file appears only here, never in custom. An incomplete collection has no per-file statuses.

ok for the collection means the stored reports are intact; it does not mean every file was accepted. A policy that reads a report must therefore require both statuses: a rule that only checks a report's contents denies nothing when the report is missing, for example because the scanner hook was left out of the stack:

package zenfra

deny contains msg if {
    input.zenfra.input_status.custom_inputs != "ok"
    msg := "scanner report missing: custom inputs are not ok"
}

deny contains msg if {
    object.get(input.third_party_metadata.custom_status, "tfsec", "missing") != "ok"
    msg := "tfsec report missing or rejected"
}

deny contains msg if {
    some r in input.third_party_metadata.custom.tfsec.results
    r.severity == "CRITICAL"
    msg := sprintf("tfsec: %s", [r.rule_id])
}

The Require a scanner report and Deny scanner failed checks templates do this for a Checkov report.

Trust. A custom input is neither sanitized nor signed. ok proves the file arrived intact, not that a scanner wrote it: a repository can commit tfsec.custom.zenfra.json itself. Never put a secret in a report. It is stored with the run, and a finding that quotes it is redacted only where the automatic redaction recognizes it.

Private workers. Custom inputs need ghcr.io/zenfracloud/zenfra-worker:main-55fb0b7 or a later image. An older worker collects nothing, and the input of its evaluated runs shows custom_inputs as absent. The worker advertises no capability for this, so check the image the pool runs; see Upgrading.

Limits

Limit Value When exceeded
Time from the claim of the run to the upload of the plan and the reports 15 minutes An upload after it fails: without the stored plan, a run bound for approval fails; without the plan's JSON form, the run gets Policy not evaluated when policies apply; a report that misses it is upload_failed
One report 1 MiB too_large
All reports of a run 5 MiB too_large
Report files in the project directory 128 incomplete
The plan in JSON form 32 MiB Policy not evaluated
The input, stored compressed 8 MiB Policy not evaluated
The input, uncompressed 64 MiB Policy not evaluated

The 15 minutes cover preparing the workspace, init, the plan and every plan-side hook. A limit is never met by cutting the input short: an input over its limit is an evaluation error. A destroy run produces no plan file and is not evaluated, so nothing is collected for it.

Authoring a policy

Write a policy against a real input before it governs anything: look at a run's input with View policy input, draft the rule, check the draft against the same run with Dry-run, then save and attach it. To start from a tested example, use one of the templates.

View policy input

View policy input in a run's Policy panel (Read role) opens Policy input: the input that the run's latest evaluation stored. It is a shape to write rules against, not the full input:

  • it keeps zenfra as evaluated, the terraform header fields, and for each resource change its address, module_address, mode, type, name, provider_name and change.actions;
  • it leaves out change.before, change.after, the sensitivity masks, after_unknown, replace_paths, index, deposed and checks, because values can be sensitive even after sanitization;
  • it replaces each custom report with its key, because reports are not sanitized; custom_status is kept.

It never builds an input. There is nothing to show for a run on a stack with no policies, a run that failed before an input existed, a run that was never evaluated, or a run whose evaluation record has expired; records are kept for 400 days.

Dry-run

The Dry-run panel in the policy editor (Admin role) evaluates the draft in the editor, alone, against one run: pick it from Recent runs or paste its Run ID, then Dry-run. The draft compiles with the same checks as saving it. Nothing is saved, no attachment is needed, and the run does not change: its status, its verdict and its approval stay as they were.

  • When the run's latest evaluation stored an input, the dry-run uses it; View policy input then shows it.
  • Otherwise it builds an input now from the run's saved plan, which needs a run that is Awaiting Approval, Applying or Finished and has a plan in JSON form; a destroy run never has one. The run and stack fields are read as they are now, not as they were when the run planned; only zenfra.stack.labels is still the run's snapshot. View latest stored policy input opens what the run stored, if anything.

So a dry-run works on a run that View policy input cannot show, for example a run on a stack that had no policies yet. The result lists the draft's findings, or reads "This draft allows the run: no deny or warn rule fired." A runtime error or the time budget is reported the same way an enforced evaluation would record it.

Testing offline

Policies can be unit-tested with OPA 1.21.1 and opa test, with tests in <name>_test.rego next to the policy:

package zenfra_test

import data.zenfra

test_delete_denied if {
    count(zenfra.deny) == 1 with input as {"terraform": {"resource_changes": [
        {"address": "aws_db_instance.main", "type": "aws_db_instance", "change": {"actions": ["delete"]}},
    ]}}
}
opa test example.rego example_test.rego

Build fixtures from the shape View policy input shows. A rule that reads change.before, change.after or a report's content needs a fixture written by hand with those values, or a dry-run, which evaluates the full input.

API equivalents

Operation Method and path Minimum role
View a run's policy input GET /api/v1/runs/{run_id}/policy-input Read
Dry-run a draft against a run POST /api/v1/policies/dry-run with source and run_id Admin

The policy input answers 404 for a run never evaluated or whose record expired, and 409 input_unavailable when the evaluation stored no input. A dry-run that has to build an input answers 409 run_not_evaluable for a run in another status, 409 plan_json_unavailable when the run has no usable plan, and 422 custom_inputs_invalid when its reports cannot be verified; a source that does not compile answers 400 with each problem's line and column.