§ B10 · Track B · pravo
Policy on providers and on plans
A constraint on a provider binds every resource built through it, and a policy file judges what a plan does.
On this page
A platform team wants every file that every team creates to have mode 0600. In OpenTofu a module sets a default, and the value the caller passes replaces it.
variable "file_permission" {
default = "0600"
}
module "keys" {
source = "./modules/keys"
file_permission = "0644"
}
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
pravo has two checks of this kind. A specification policy is a constraint on a provider definition. It binds every resource built through that provider and is checked when the specification is evaluated. A plan policy is a file that a plan must satisfy. It judges what a plan does and is named by whoever runs pravo. They are two moments of one idea: a rule written as a CUE value that something must unify with.
Pin a value for every resource
Work in an empty directory. The root lives in root, and the policies live beside it. Every command names the root with -C root, which runs it as if started there. Wrap the provider in a definition and constrain the resource inside.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "secret\n"
}
#Strict is a provider definition with one more constraint. $files is a provider built from it. Every local_file built through $files carries the mode 0600.
Configuration does not load yet; generating schemas for the declared providers first.
Created cue.mod/module.cue
Providers:
local = hashicorp/local v2.9.1
...
pravo has been initialised. Run "pravo plan" to see what it would do.
...
# key (local_file) will be created
+ key: {
+ content: "secret\n"
...
+ file_permission: "0600"
+ filename: "key.pem"
+ id: (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Graph: planned 1 of 1.
key never set a mode. The plan shows file_permission: "0600" all the same.
Break it: override the pin
Set a different mode on the resource. The block carries status=conflict.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "secret\n"
file_permission: "0644"
}
Error: evaluating ./root:
key.file_permission: 2 errors in empty disjunction:
key.file_permission: conflicting values "0600" and "0644":
./root/main.cue:6:42
./root/main.cue:11:6
./root/main.cue:14:19
key.file_permission: conflicting values "0644" and null (mismatched types string and null):
./root/cue.mod/gen/pravo.dev/providers/hashicorp/local/local_gen.cue:84:30
./root/main.cue:11:6
./root/main.cue:14:19
The path is key.file_permission. The first error names the two values, "0600" and "0644", and three places: the pin, the resource and the value. The second error appears because the generated attribute is string | null, and CUE reports each alternative that failed. No provider started and nothing was planned.
A constraint rejects a value that is set and wrong. It does not require an attribute to be set. A rule such as "every file has an owner tag" is a rule for a plan policy.
Refuse a plan
A plan policy is one CUE file. pravo unifies it with the plan document, the data that pravo plan --json prints. The plan satisfies the policy when the result is concrete and has no error. Write it outside the root.
// A plan may create and change. It may not delete.
changes: [...{
addr: string
mode: string
action: string
if mode == "managed" && action == "delete" {
error("\(addr): delete is not allowed")
}
}]
changes is a list template: every change must have these fields. The if is a guard. It adds an error to a managed resource whose action is delete, and the message names the address.
Restore the root and add a second file. Apply it without a policy.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "secret\n"
}
note: $files.#Resource.local_file & {
filename: "note.txt"
content: "note\n"
}
...
Plan: 2 to add, 0 to change, 0 to destroy.
Graph: planned 2 of 2.
...
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <txn>]
Add a third file and plan it under the policy. The plan creates and does not delete. The path of the policy is relative to the root, because -C root runs the command there.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "secret\n"
}
note: $files.#Resource.local_file & {
filename: "note.txt"
content: "note\n"
}
log: $files.#Resource.local_file & {
filename: "log.txt"
content: "log\n"
}
...
# log (local_file) will be created
+ log: {
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Graph: planned 1 of 3, 2 unchanged and skipped, 2 not read.
Policy ../policy/no-delete.cue (sha256:a1757b6a34e5) is satisfied.
Saved the plan to add.plan. To apply exactly these actions, run:
pravo apply add.plan
The plan is printed first. The verdict gives the file name, twelve digits of the SHA-256 of its bytes, and is satisfied. The digest identifies the bytes that judged the plan.
Under a policy, an apply is of a saved plan.
Policy ../policy/no-delete.cue (sha256:a1757b6a34e5) is satisfied.
log: Creating...
log: Creation complete after 0s [id=4508c1b19b8841345afeed30a6d835cca066d5e5]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed. [txn <txn>]
Break it: a plan that deletes
Remove note from the root. The plan deletes it. The block carries status=unintended: the specification is valid, and the plan breaks the policy.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "secret\n"
}
log: $files.#Resource.local_file & {
filename: "log.txt"
content: "log\n"
}
The specification is valid. Read what the plan does to note.
Predict Does the plan pass the policy? Which exit code does the run give?
The plan deletes note, so the policy refuses it. The exit code is 5, and the plan is not saved.
...
# note (local_file) will be destroyed
- note: {
...
}
Plan: 0 to add, 0 to change, 1 to destroy.
Graph: planned 0 of 2, 2 unchanged and skipped, 1 removed from the specification, 1 refreshed, 2 not read.
Error: the plan violates the policy ../policy/no-delete.cue (sha256:a1757b6a34e5):
changes.2: note: delete is not allowed:
../policy/no-delete.cue:7:3
The plan is printed, then judged, and the run exits with code 5. The error has three parts.
- The policy file and its digest.
- The path
changes.2. It is a position in the plan document. That list includes unchanged resources, so it is not a count of the blocks above. - The message from
error(), and the line in the policy that wrote it.
A refused plan is not saved.
add.plan
cue.mod
key.pem
log.txt
main.cue
note.txt
pravo.lock.cue
pravo.state
There is no delete.plan. A plain apply makes its own plan as it goes, so a policy refuses it.
Error: a policy is judged against a saved plan: run "pravo plan -out <file>", then "pravo apply <file>"
Rehearse a policy on a fixture
A policy can be tested without a plan run. pravo plan --json --include-skipped prints the plan document with an entry for every node, which is data and is what a policy is judged against. Save it as CUE next to the policy, in one package, and unify them. pravo eval -c fails when the policy is violated.
Error: evaluating ./fixture:
changes.2: note: delete is not allowed:
./fixture/policy.cue:8:3
The verdict is the one --policy gives. Once saved, the fixture needs no state and no provider. A pipeline can test the policy on every change to the policy file. The line number is one more than in the policy, because the first line of the file is package fixture.
What a policy does not bind
A policy binds the runs that name it. Save the same deletion without a policy and give it to apply in two ways.
...
Plan: 0 to add, 0 to change, 1 to destroy.
Graph: planned 0 of 2, 2 unchanged and skipped, 1 removed from the specification, 1 refreshed, 2 not read.
Saved the plan to delete.plan. To apply exactly these actions, run:
pravo apply delete.plan
Error: the plan violates the policy ../policy/no-delete.cue (sha256:a1757b6a34e5):
changes.2: note: delete is not allowed:
../policy/no-delete.cue:7:3
Nothing was applied. Now leave the flag out.
note: Destroying... [id=4b61f9110fdc6c1d4ddb0e04f8e31621e755a4f4]
note: Destruction complete after 0s
Apply complete! Resources: 0 added, 0 changed, 1 destroyed. [txn <txn>]
key
log
The deletion ran. A run that omits --policy is not checked. That includes a laptop, a shell that an agent holds, and a pipeline step with the flag missing.
A specification policy has the same limit. It binds the resources built through its provider. A root can declare another provider from loc.#Provider and build a resource through that one.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
$other: loc.#Provider
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "secret\n"
}
log: $files.#Resource.local_file & {
filename: "log.txt"
content: "log\n"
}
probe: $other.#Resource.local_file & {filename: "probe.txt", content: "x\n", file_permission: "0644"}
Success! 3 resources, 0 data sources, 0 ephemeral, 0 function calls, 0 outputs across 1 providers.
The mode 0644 passes, because $other carries no pin. The platform decides which provider structs a root is built from, for example by reviewing the files that declare them.
What a policy guarantees depends on four rights.
| Right | Whoever holds it can |
|---|---|
| Write the policy file | Change the policy or empty it. |
Write the job that spells --policy |
Drop the flag or name another file. |
| Write the state | Change what pravo believes exists, with pravo state rm or pravo state mv. No plan is made, so no policy sees it. |
| Hold the cloud credentials | Change infrastructure without running pravo. |
Give the first two rights to the people who review policies. Nothing in pravo covers the last two.
The specification cannot name a policy
The $settings block takes state and refresh. Naming a policy there is an error. The specification cannot weaken the rule that judges it.
Error: $settings.policy is not a setting (the block takes state and refresh)
Exercise
Refuse replacements too
A change of a file's content replaces the file. The policy as written does not refuse a replacement. Extend it so that it does. Change the content of key so that the plan replaces it. Prove the rule on a fixture before you use it.
Hint 1
The action of a replacement is replace. The guard already reads action. Combine the two names with ||.
Hint 2
Make the fixture as in Rehearse a policy on a fixture, from the plan that replaces key. Use mkdir -p, because the directory exists.
Solution
// A plan may create and change. It may not delete or replace.
changes: [...{
addr: string
mode: string
action: string
if mode == "managed" && (action == "delete" || action == "replace") {
error("\(addr): \(action) is not allowed")
}
}]
Change the content of key so that the plan replaces it.
package main
import loc "pravo.dev/providers/hashicorp/local"
#Strict: loc.#Provider & {
#Resource: local_file: file_permission: "0600"
}
$files: #Strict
key: $files.#Resource.local_file & {
filename: "key.pem"
content: "new secret\n"
}
log: $files.#Resource.local_file & {
filename: "log.txt"
content: "log\n"
}
Save the plan document and the policy as a fixture, and judge it.
Error: evaluating ./fixture:
changes.0: key: replace is not allowed:
./fixture/policy.cue:8:3
The same plan under --policy gives the same verdict.
...
# key (local_file) must be replaced
-/+ key: {
~ content: "secret\n" -> "new secret\n" # forces replacement
...
}
Plan: 1 to add, 0 to change, 1 to destroy.
Graph: planned 1 of 2, 1 unchanged and skipped, 1 refreshed, 1 not read.
Error: the plan violates the policy ../policy/no-delete.cue (sha256:3aeafc5246d4):
changes.0: key: replace is not allowed:
../policy/no-delete.cue:7:3
Clean up
Destroy complete! Resources: 0 added, 0 changed, 2 destroyed. [txn <txn>]
Recap
- A constraint on a provider definition binds every resource built through it. A conflict is an error before any provider starts.
- A plan policy is a file named with
--policy, and a saved plan document is a fixture that tests it. A violation exits with code 5, and a refused plan is not saved. - A policy binds the runs that name it. Omitting the flag, editing the state and calling the cloud directly are outside it.
Check yourself
Which exit code means a plan violated a policy?
Exit code 5. The error names the policy file, its digest, the path in the plan document and the line in the policy.
Why does a plain pravo apply fail when a policy is named?
A plain apply makes its own plan as it goes. Under a policy, an apply is of a saved plan that was judged.
The how-to pages cover writing and rolling out policies: Policies. The reference describes the plan document: Plan document.
The project combines every lesson of this track. Lab 1 breaks a root ten ways to show the exit codes.