A rule that lives in a document is a rule somebody remembers or does not. A rule that lives in code is a rule that runs.
The interesting engineering is not writing the rules — it is deciding where they run, because the same rule enforced at three different layers gives three different guarantees.
Three layers, three guarantees
Section titled “Three layers, three guarantees”| Where | Enforced for | Bypassable by |
|---|---|---|
| A document | Nobody | Everybody |
| CI | Anything going through the pipeline | Anything not going through the pipeline |
| Admission | Every request to the API | Nothing — it is the API |
A document has zero enforcement and is still worth writing, because it explains why. A rule with no rationale gets deleted the first time it is inconvenient.
CI catches mistakes. A pull request that would violate a rule fails before merge, with fast feedback and a clear message. It does not catch anything that reaches the system another way — a console change, a kubectl apply, a pipeline in another repository.
Admission catches everything. A validating webhook or an admission policy evaluates every request to the Kubernetes API regardless of origin. That is a genuine control rather than a check.
The layering that works: the rationale in a document, fast feedback in CI, and enforcement at admission for anything that must hold. The same rule at two layers is not duplication — it is a fast failure and a real guarantee.
Where policies live
Section titled “Where policies live”In the repository they govern, for rules specific to that estate. A change to a rule and a change to the configuration it constrains can be reviewed together, and the history shows both.
In a central policy repository, for rules that apply across the organisation. Versioned, released and consumed like any other shared artifact — with the same pinning discipline, because a floating reference means the central team changes what everybody’s pipeline enforces without a pull request anywhere.
Most organisations want both. A small central set that cannot be overridden, plus repository-local rules teams own. The central set should be genuinely universal — a rule wrong for one repository is wrong on every check there, with no clean local override.
Under CODEOWNERS. A policy change alters what is enforced for everybody, which is a governance change and should be reviewed as one.
What to write rules about
Section titled “What to write rules about”The rules worth having encode an operational fact rather than a preference.
Infrastructure:
- No security group rule opening a port to the internet
- No unencrypted storage
- No resource without required tags
- No plan that destroys a resource tagged
protected - No public storage bucket
- A plan destroying more than n resources requires a second approval
Kubernetes:
- No container running as root
- No
privileged: true, nohostNetwork, nohostPath - Resource requests and limits present
- No
latestimage tags - Images only from approved registries
- Probes defined on anything serving traffic
- No bare pods — everything owned by a controller
Repository and process:
- Required status checks on protected branches
- Code owner review on specified paths
- No direct pushes
- Signed commits, where you have adopted them
The distinction that matters: the first two lists are policy engines evaluating a proposed change. The third is rulesets, which is policy as code by a different name and is frequently the highest-value layer because it governs the process everything else runs through.
Evaluating a plan, not just source
Section titled “Evaluating a plan, not just source”The strongest form of infrastructure policy.
Source analysis reads the configuration and asks “does this look wrong?”. Useful and limited: it cannot see that a change destroys a database, because that fact only exists once the plan is computed against state.
Plan analysis reads terraform show -json output and asks “will this do something prohibited?”. Strictly stronger for anything about effect.
package terraform.deny
import rego.v1
deny contains msg if { some change in input.resource_changes "delete" in change.change.actions change.change.before.tags.protected == "true" msg := sprintf("Cannot destroy protected resource %s", [change.address])}The rules worth writing at this layer are about effect: destruction, replacement of stateful resources, changes to network exposure, and anything crossing a threshold.
Escalation beats blocking, sometimes. A rule that adds a required reviewer when the destroy count is non-zero applies friction proportionate to risk, without training people to bypass it on routine changes. Blocking everything equally is how a policy set becomes something to work around.
Kubernetes admission
Section titled “Kubernetes admission”Where policy becomes a real control rather than a check.
Validating admission rejects a request that violates a rule. Every request, regardless of who sent it or how.
Mutating admission modifies a request — adding a default, injecting a label, setting a security context. Powerful and worth using sparingly, for two reasons. A cluster where the stored object differs from what was submitted is one where debugging is harder, because the manifest in the repository is not what is running. And GitOps drift detection will report the mutation as a difference on every reconciliation unless you configure the controller to ignore exactly those fields — which is a maintenance burden that grows with every mutating policy.
The two common approaches: a general-purpose policy engine with its own language, or a Kubernetes-native one where policies are custom resources written in YAML. The second has a lower learning curve and fits a GitOps repository naturally, because policies are just more manifests. The first is more expressive and reusable across domains.
Kubernetes also has built-in admission policy using CEL expressions, which covers a useful subset without installing anything. Worth knowing about before adopting a controller for a handful of simple rules.
Start in audit mode. A policy that reports violations without blocking tells you what would break before it breaks. Existing clusters always violate new policies — including the system components you did not write — and discovering that by blocking every deployment is entirely avoidable.
Exclude the system namespaces, or a policy will reject something the cluster needs to function.
Policies are cluster-wide and are themselves a blast radius. A badly written validating webhook that fails closed can block every request to the API — including the ones you would use to remove the webhook. Recovering from that means reaching the API through a path the webhook does not intercept, which is a bad thing to be working out during the incident. Test policies in a disposable cluster, always, and know in advance how you would remove a broken one.
Testing policies
Section titled “Testing policies”A rule that never fires is worse than no rule, because it produces a passing check that means nothing.
Fixture-based tests. A plan or manifest that should pass, one that should fail, and an assertion for each. Both policy engines and most linters support this.
package terraform.deny_test
import rego.v1import data.terraform.deny
test_destroying_protected_resource_is_denied if { result := deny with input as { "resource_changes": [{ "address": "aws_db_instance.primary", "change": { "actions": ["delete"], "before": {"tags": {"protected": "true"}}, }, }] } count(result) == 1}
test_destroying_unprotected_resource_is_allowed if { result := deny with input as { "resource_changes": [{ "address": "aws_instance.scratch", "change": {"actions": ["delete"], "before": {"tags": {}}}, }] } count(result) == 0}Both directions matter. A rule that denies everything passes the first test and is useless.
Run the tests in CI, on the policy repository. Policies are code and they regress like code.
Test with real fixtures. A plan captured from an actual change, with the sensitive values removed, is a better test than a hand-written minimal one — it exercises the shape the policy will actually see.
Version and release policies like any shared artifact, with notes describing what changed for consumers.
Exceptions
Section titled “Exceptions”Every policy set needs an exception mechanism, and the mechanism determines whether the policy set survives.
No exception mechanism means people disable the policy or work around it. That is the worst outcome, because the bypass is invisible.
An inline exemption with a reason is reviewable:
metadata: annotations: policy.example.org/exempt-run-as-root: >- Legacy agent requires root for cgroup access. Tracked in PLAT-4821, removal planned for Q1.A time limit on the exemption, checked by another rule. An exception with an expiry is a decision; one without is a permanent hole with a comment.
An approval requirement. An exemption that requires a code owner’s approval is one somebody looked at.
A register of active exemptions, reviewed periodically. Exemptions accumulate, and reviewing the list annually is what stops the policy set becoming a formality with forty holes in it.
The signal that the policy is wrong rather than the situation: the same exemption appearing repeatedly. Three exemptions for the same rule mean either the rule is too strict or the standard has changed, and either way the rule is the thing to fix.
Rolling it out
Section titled “Rolling it out”-
Write the rule as a document first, with the reason. If you cannot state why, the rule is not ready.
-
Implement it in audit mode. Report violations, block nothing. Run for a week.
-
Count the existing violations. Every mature estate violates every new policy. The count tells you whether this is a rule or a project.
-
Fix or exempt each existing violation, deliberately. This is the actual work and it is where the value is — the policy is a mechanism for finding them.
-
Enable blocking in CI, so new violations fail a pull request with fast feedback.
-
Enable blocking at admission for anything that must hold, once CI has been clean for a while.
-
Add the exception mechanism before you need it, not after somebody is blocked at 2am.
-
Review the exemption register quarterly.
Step 3 is the decision point. Two hundred violations means this is a programme rather than a policy, and enabling blocking would stop all work in the repository. Baseline what exists, block only what is new, and reduce the baseline as its own piece of work with its own schedule. A baseline is not a defeat — it is the difference between a policy that gets adopted and one that gets switched off in week two.
Steps 5 and 6 are different guarantees. Do not skip 6 for rules that genuinely must hold, and do not add 6 for rules that are advisory.
Writing a good rule
Section titled “Writing a good rule”The difference between a policy set people work with and one they work around.
Be specific about what is prohibited. “Containers must be secure” is not a rule. “No container may set securityContext.privileged: true” is.
Say why, in the message. A denial reading “policy violation: PSP-003” tells the developer to find somebody. One reading “containers must not run as root — set runAsNonRoot: true, or request an exemption in PLAT-4821” tells them what to do.
Name the resource and the field. A message that identifies which of forty resources failed saves a search.
Make the fix obvious. The best denial messages contain the corrected snippet.
Prefer rules about the effect over rules about the form. “No resource with a public IP” is about effect; “no field named public_ip” is about form, and it misses every other way to expose something.
Avoid rules that duplicate a type system. If the provider rejects an invalid value, a policy checking the same thing adds latency and no coverage.
Avoid rules about style. Formatting belongs in a formatter. A policy engine reporting indentation is a policy engine people stop reading.
Write the rule so it can be tested. If you cannot construct an input that should pass and one that should fail, the rule is not well specified — and that is usually a sign it encodes a preference rather than a requirement.
Policy and the review process
Section titled “Policy and the review process”Policy and human review are complementary, and getting the division right prevents both from being wasted.
Policy catches what is mechanically checkable. An unencrypted volume, a public bucket, a missing tag, a destroy on a protected resource. Reliably, on every change, without depending on who reviewed it.
Review catches what is not. Whether this change should exist, whether the approach is right, whether the operational timing is acceptable. Those depend on context that is not in the diff.
Every rule you encode is attention returned to the reviewer. A reviewer who no longer has to check for missing resource limits is a reviewer with more attention for whether the change makes sense.
The signal to encode something: a reviewer correcting the same thing repeatedly. The third time somebody comments about a missing tag, that comment should be a rule.
The signal that a rule is wrong: reviewers routinely approving exemptions for it. That means the standard has moved and the rule has not.
What policy must not do is replace the review. A pull request that passes every check has satisfied every mechanical criterion, which is a different claim from being a good change — and a team that treats a green pipeline as approval has removed the layer that catches everything the rules do not cover.
Common mistakes
Section titled “Common mistakes”Rules in a document only. Zero enforcement.
CI-only enforcement for something that must hold. Protects you from your own pipeline.
Blocking without a baseline. Two hundred failures, then the policy is disabled.
No exception mechanism. People disable the policy invisibly.
Exemptions with no expiry or reason. Permanent holes.
Untested policies. A rule that never fires produces a passing check that means nothing.
Testing only the deny case. A rule that denies everything passes.
Deploying an admission webhook untested. A fail-closed webhook can block every request including the ones that would remove it.
Central policies referenced by a floating version. Everybody’s enforcement changes with no pull request.
Mutating admission used liberally. Objects differ from what was submitted, and drift detection reports things that are not drift.
Owning the policy set
Section titled “Owning the policy set”Policies govern everybody, which makes their ownership an organisational question.
Somebody must own each rule. A policy nobody owns is one nobody can change when it turns out to be wrong, so people route around it instead.
Security usually owns the security rules and does not own all of them. A rule about resource limits is a reliability rule, and the platform team owns it.
Teams should be able to propose rules. A team that keeps hitting the same mistake is a team with a rule to contribute, and a policy set that only flows from a central team misses most of what is worth encoding.
Central rules should be few. Every rule applied everywhere is a rule that must be right everywhere, and one wrong for a single repository is wrong on every check there.
Publish the rule set. Developers hitting a denial should be able to read the rule and its rationale without asking. A policy repository with a README listing every rule and why it exists is the cheapest possible reduction in friction.
Review the set annually. Rules encode a moment’s understanding. Some become obsolete, some become insufficient, and some were always wrong and nobody wanted the argument.
Watch for rules that never fire. Either everybody complies — good, and worth confirming rather than assuming — or the rule is broken and produces a passing check that means nothing. Instrumenting which rules actually trigger tells you which.
When policy is the wrong tool
Section titled “When policy is the wrong tool”Being honest about the limits, because policy engines attract over-application.
Do not encode a decision that needs judgement. “No deployment on Fridays” as a hard rule blocks the emergency fix on a Friday. That is a norm, not a policy.
Do not encode something the platform enforces. A cloud provider rejecting an invalid configuration does not need a policy saying the same thing.
Do not encode a preference. Naming conventions, comment style, file organisation. Those belong in a linter or a review comment, and enforcing them with the same machinery as security rules dilutes both.
Do not use policy as documentation. A rule with no enforcement configured is a document in an inconvenient format.
Do not build a policy for a one-off. A rule exists because the situation recurs. A single bad change is a review finding.
Where a rule keeps needing exemptions, it is describing a constraint you do not actually have. Delete it rather than maintaining a register of exceptions to something nobody believes.
The test: would you be comfortable if this rule blocked somebody at 2am during an incident? If not, it should be advisory, escalating, or not a policy at all.
Policy across the tools in this pillar
Section titled “Policy across the tools in this pillar”The same idea appears in each cluster with different mechanics, and seeing them together clarifies the layering.
Terraform and OpenTofu. Source scanning catches known-bad configuration; plan evaluation catches prohibited effects. Neither prevents somebody applying from a laptop with the right credentials — that is what the cloud provider’s own policy layer and least-privilege roles are for.
Containers. Base image restrictions, no latest, required labels, signature verification. Enforced in the build pipeline, and at admission in the cluster that runs them — the second is what catches an image built elsewhere.
Kubernetes. The richest layer, because admission control is built into the API.
Ansible. ansible-lint is a policy engine with a preloaded rule set, and custom rules extend it. There is no admission equivalent — a playbook run by somebody with SSH access is not intercepted by anything — which is why the validation and execution separation carries more weight there.
The repository itself. Rulesets governing review, checks and pushes. Frequently the highest-value layer, because everything else runs through the process it governs.
The pattern across all five: where the system has an enforcement point at the boundary — an admission controller, a cloud policy service — that is where rules that must hold belong. Where it does not, as with Ansible, the boundary is who holds the credentials, and policy in CI is a check rather than a control. Knowing which situation you are in is what stops a team believing it has a guarantee it does not have.
Mental model
Section titled “Mental model”A policy is a rule expressed so that a machine enforces it. Where it runs decides what it guarantees: a document guarantees nothing, CI guarantees the pipeline, and admission guarantees the system.
The corollary that decides most designs: write the rule once, enforce it at the layer that matches its importance, and accept that a rule which must hold has to live where changes actually land — not where they are proposed.
What you learned
Section titled “What you learned”- Three layers with different guarantees: document, CI, admission
- A rule enforced only in CI protects you from your pipeline and nothing else
- Plan evaluation is stronger than source analysis for anything about effect
- Escalation — adding a reviewer — is often better than blocking
- Start in audit mode; every mature estate violates every new policy
- Test both directions: a rule that denies everything passes a single deny test
- Exceptions need a reason, an expiry and a review, or they become permanent
- The same exemption three times means the rule is wrong, not the situation
Exercise
Section titled “Exercise”Use a disposable repository and a local Kubernetes cluster.
-
Write a rule in a document: “no container runs as root”. Count how many of your manifests violate it.
-
Implement it as a CI check. Predict: how many pull requests would it have blocked in the last month?
-
Try to violate it by applying a manifest directly with
kubectl. Predict: does the CI rule stop you? -
Implement it as an admission policy in audit mode. Apply the violating manifest. Predict: what is reported, and does it apply?
-
Switch the policy to enforce. Apply again.
-
Add an exemption annotation with a reason, and a second rule requiring the annotation to include an expiry date. Test both.
-
Write a policy test with a passing fixture and a failing fixture. Then break the rule so it denies everything. Predict: which test catches it?
-
Delete the cluster and the repository.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.