§ 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.

Time
20 min
Level
intermediate
Needs
B9
You will learn
How do you pin a value for every resource of a provider? How do you refuse a plan, and what does a policy leave unchecked?
Done when
A file mode cannot be overridden. A plan that deletes is refused with exit code 5. You can say which runs the policy does not cover.

View as Markdown

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.

modules/keys/variables.tf
variable "file_permission" {
  default = "0600"
}
main.tf
module "keys" {
  source          = "./modules/keys"
  file_permission = "0644"
}
main.cue
#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.

CUE files evaluate plan judge apply specification policy constraint on the provider validate: exit 6 plan policy file named with --policy plan, apply: exit 5
fig. 1 A specification policy is checked when the specification is evaluated. A plan policy is checked when the plan is judged.

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.

root/main.cue
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.

terminal
$pravo -C root init
$pravo -C root plan
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.

root/main.cueconflict
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"
}
terminalexit 6
$pravo -C root validate
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.

policy/no-delete.cue
// 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.

root/main.cue
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"
}
terminal
$pravo -C root apply --auto-approve
...
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.

root/main.cue
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"
}
terminal
$pravo -C root plan --out add.plan --policy ../policy/no-delete.cue
...
  # 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.

terminal
$pravo -C root apply add.plan --policy ../policy/no-delete.cue
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.

root/main.cueunintended
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.

terminalexit 5
$pravo -C root plan --out delete.plan --policy ../policy/no-delete.cue
...
  # 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.

terminal
$ls root
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.

terminalexit 5
$pravo -C root apply --auto-approve --policy ../policy/no-delete.cue
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.

terminalexit 6
$mkdir fixture
$(echo "package fixture"; pravo -C root plan --json --include-skipped 2>/dev/null) > fixture/plan.cue
$(echo "package fixture"; cat policy/no-delete.cue) > fixture/policy.cue
$pravo -C fixture eval -c
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.

terminal
$pravo -C root plan --out delete.plan
...
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
terminalexit 5
$pravo -C root apply delete.plan --policy ../policy/no-delete.cue
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.

terminal
$pravo -C root apply delete.plan
$pravo -C root state list
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.

root/main.cueunintended
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"}
terminal
$pravo -C root validate
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.

terminalexit 6
$printf 'package main\n\n$settings: policy: "../policy/no-delete.cue"\n' > root/setting.cue
$pravo -C root plan
Error: $settings.policy is not a setting (the block takes state and refresh)
terminal
$rm root/setting.cue

Exercise

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
policy/no-delete.cue
// 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.

root/main.cue
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.

terminalexit 6
$mkdir -p fixture
$(echo "package fixture"; pravo -C root plan --json --include-skipped 2>/dev/null) > fixture/plan.cue
$(echo "package fixture"; cat policy/no-delete.cue) > fixture/policy.cue
$pravo -C fixture eval -c
Error: evaluating ./fixture:
changes.0: key: replace is not allowed:
    ./fixture/policy.cue:8:3

The same plan under --policy gives the same verdict.

terminalexit 5
$pravo -C root plan --policy ../policy/no-delete.cue
...
  # 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

terminal
$pravo -C root destroy --auto-approve | tail -n 1
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.