Pulumi Policy as Code with CrossGuard
Guardrails belong in the deployment path, not in a wiki. Pulumi CrossGuard lets you express organisational rules — encryption, tagging, instance sizing, network exposure — as typed Python that runs on every pulumi preview and blocks non-compliant changes before they reach the cloud. This topic, part of Pulumi patterns and provider management, covers how policy packs work and links to concrete policies you can adopt.
Two guides sit under this topic. Writing Pulumi CrossGuard policies in Python builds a pack from an empty directory up to a rule that fails a preview because a security group exposes port 22 to the internet — read it when you want the mechanics end to end. Enforcing tagging policies with Pulumi CrossGuard takes the single most-requested rule — a mandatory owner / env / cost-center tag set — and shows how to make it configurable so one pack serves several teams. This page is the map: what a pack is, how the runtime invokes it, and how to roll one out without stopping every deploy in the organisation on day one.
Problem Framing
Reviews catch some misconfigurations, but humans miss the fifth unencrypted bucket of the day. Policy as code makes the rule executable: it evaluates the resource graph Pulumi is about to apply and returns advisory warnings or hard failures. Because the policies are Python, they live in version control, get unit tested, and evolve with the same workflow as the infrastructure they govern — an extension of the security and compliance basics every team needs.
The specific gap CrossGuard fills is timing. Cloud-side detective controls — AWS Config rules, Azure Policy, GCP Organization Policy — report a violation after the resource exists. By then someone has to decide whether to delete a production bucket or accept a finding. Static scanners run earlier but read only what is written down; they cannot see a bucket name that is computed at deploy time, a security group whose ingress list is assembled in a loop, or an instance type that comes from stack configuration. CrossGuard runs in the gap: after Pulumi has resolved the program into a concrete set of resource inputs, but before a single API call goes out.
That position has a second consequence worth planning for. Because policy evaluation happens on the planned graph rather than on the deployed one, it sees the inputs Pulumi is about to send, not the outputs the provider will return. A rule that asks "does this bucket have server-side encryption configured?" works. A rule that asks "what ARN did AWS assign?" does not, because during a preview of a new resource that value is still unknown. Designing rules around inputs rather than computed outputs is the single biggest determinant of whether a pack behaves predictably.
Prerequisites
- Python 3.9+ with
pulumi>=3.0andpulumi-policyinstalled in a separate policy project - A stack you can preview, and permission to register the policy pack with
--policy-pack - Agreement on which rules are advisory (warn) versus mandatory (fail), so the pack does not block every deploy on day one
A policy pack is a Python project in its own right, with its own virtual environment. It is deliberately not part of the infrastructure project: the pack has to be usable against many stacks, and mixing its dependencies with the program's provider SDKs creates version conflicts you do not want to debug during an incident. The layout the CLI scaffolds with pulumi policy new aws-python is three files — PulumiPolicy.yaml, requirements.txt, and __main__.py.
# PulumiPolicy.yaml — the manifest that makes a directory a policy pack
# CLI: pulumi policy new aws-python --dir ./policy
name: acme-baseline
runtime: python
description: Baseline encryption, tagging and exposure rules for ACME.
# CLI: confirm the policy SDK is importable
python -c "import pulumi_policy; print('policy sdk ok')"
The name in PulumiPolicy.yaml must match the name passed to the PolicyPack constructor in __main__.py. If they disagree the CLI reports the mismatch and refuses to load the pack, which is a five-minute detour the first time it happens. Pin pulumi-policy in requirements.txt the same way you pin provider SDKs, because the shape of the arguments objects has grown over releases and a rule written against a newer field silently stops matching on an older runtime.
How Policy Packs Work
A policy pack is a small Python program that registers a PolicyPack with one or more ResourceValidationPolicy rules. Each rule receives a resource's type and properties and reports a violation string when the resource breaks the rule.
# __main__.py — a minimal policy pack requiring S3 encryption
# CLI: pulumi preview --policy-pack ./policy
from pulumi_policy import (
PolicyPack, ResourceValidationPolicy, EnforcementLevel, ReportViolation)
def s3_encrypted(args, report: ReportViolation):
if args.resource_type == "aws:s3/bucketV2:BucketV2":
if not args.props.get("serverSideEncryptionConfiguration"):
report("S3 buckets must define server-side encryption.")
PolicyPack(
name="acme-baseline",
enforcement_level=EnforcementLevel.MANDATORY,
policies=[ResourceValidationPolicy(
name="s3-encryption-required",
description="All S3 buckets must be encrypted.",
validate=s3_encrypted)])
Provider note: rules see the same property names Pulumi sends to the provider, so consult the resource schema when matching fields.
The runtime side is worth understanding because it explains most surprising behaviour. When you pass --policy-pack ./policy, the Pulumi CLI starts the pack as a separate analyzer process and speaks to it over gRPC. As the engine walks the program and registers each resource, it forwards the resource's type token, logical name, URN, parent, and fully resolved input properties to the analyzer, which runs every ResourceValidationPolicy in the pack against it. Violations flow back as diagnostics attached to that resource's URN, which is why the CLI can print the offending resource next to the message.
Two details follow from that design. First, the analyzer is a separate process with a separate interpreter, so anything your rules import must be in the pack's requirements.txt — importing pulumi_aws in the infrastructure project does nothing for the pack. Second, rules run during preview and during update, so a mandatory violation aborts pulumi up as well, even when the preview was approved earlier in the same session.
The args object passed to a resource rule is a ResourceValidationArgs, and it carries more than the two fields most examples use:
# rules/context.py — every field a resource rule can read
# CLI: pulumi preview --policy-pack ./policy --stack dev
from typing import Any, Dict
from pulumi_policy import ResourceValidationArgs, ReportViolation
def describe(args: ResourceValidationArgs, report: ReportViolation) -> None:
props: Dict[str, Any] = args.props # resolved INPUT properties
urn: str = args.urn # full URN, useful in messages
name: str = args.name # logical resource name
protect: bool = args.opts.protect # ResourceOptions seen by the engine
# Provider note: args.provider is None when the default provider is used.
if args.provider is not None and args.provider.props.get("region") == "us-east-1":
report(f"{name} pins us-east-1; use the regional provider instead.", urn)
Passing the URN as the second argument to report matters more than it looks: without it the diagnostic is attached to the stack rather than the resource, and an operator reading a failed pipeline sees "something violated the tagging rule" instead of "app-assets violated the tagging rule". Always pass it when the rule is about a specific resource.
Resource Validation versus Stack Validation
ResourceValidationPolicy is the workhorse, but it is structurally incapable of answering questions that span resources. "Every RDS instance must sit in a subnet group that spans at least two availability zones" and "this stack may not exceed twelve NAT gateways" are both invisible to a per-resource callback, because the callback never sees a second resource. That is what StackValidationPolicy is for.
A stack policy is invoked once, after the engine has finished registering every resource, and receives a StackValidationArgs whose resources attribute is a list of PolicyResource objects. Each of those carries the same resource_type and props a resource rule would see, plus dependencies and property_dependencies, so you can walk the edges of the graph rather than just the nodes.
# rules/stack_rules.py — a rule that needs the whole graph
# CLI: pulumi preview --policy-pack ./policy --stack prod
from pulumi_policy import (
StackValidationArgs, StackValidationPolicy, ReportViolation, EnforcementLevel)
NAT_TYPE = "aws:ec2/natGateway:NatGateway"
def nat_budget(args: StackValidationArgs, report: ReportViolation) -> None:
gateways = [r for r in args.resources if r.resource_type == NAT_TYPE]
if len(gateways) > 12:
# State implication: the count is of PLANNED resources, deletions included.
report(f"Stack plans {len(gateways)} NAT gateways; the ceiling is 12.")
nat_ceiling = StackValidationPolicy(
name="nat-gateway-ceiling",
description="Caps NAT gateways per stack to control fixed hourly spend.",
validate=nat_budget,
enforcement_level=EnforcementLevel.ADVISORY)
Cost rules are the most common reason teams reach for stack validation, and they overlap with the estimation work described in IaC cost and governance. A StackValidationPolicy is the right tool when the number you care about is a sum or a count; a dedicated cost guardrail check is the right tool when you need a currency figure from a pricing API, because policy evaluation should stay fast and offline.
The trade-off is cost and blame. A stack rule holds the whole planned graph in memory, and because it is not tied to one resource, its violations attach to the stack unless you pass a URN explicitly. Reach for a resource rule first; escalate to a stack rule only when the question genuinely requires more than one resource to answer.
Enforcement Levels and the Promotion Ladder
EnforcementLevel is per-rule, not just per-pack. The value you pass to PolicyPack(enforcement_level=...) is a default; each ResourceValidationPolicy can override it. That is what makes an incremental rollout possible: a single pack can carry three rules that fail the build and four that only warn, and rules move between the two states as the organisation's confidence grows.
EnforcementLevel.ADVISORYprints the violation and lets the operation continue. Use it for every new rule, without exception.EnforcementLevel.MANDATORYfails the preview and the update. Promote a rule here only after an advisory period long enough to cover the slow-moving stacks.EnforcementLevel.DISABLEDkeeps the code in the pack but stops evaluating it. Preferable to deleting a rule you may need again, and preferable to commenting it out, because the description stays visible inpulumi policy ls.
Hard-coding levels in Python means every change is a pack release. Config schemas avoid that: declare the knobs a rule accepts, and let the consuming pipeline supply them from a JSON file. The level itself is always available as enforcementLevel without you declaring it.
# rules/sizing.py — a configurable rule with a declared schema
# CLI: pulumi preview --policy-pack ./policy --policy-pack-config ./levels.json
from typing import Any, Dict, List
from pulumi_policy import (
EnforcementLevel, PolicyConfigSchema, ReportViolation,
ResourceValidationArgs, ResourceValidationPolicy)
def instance_sizes(args: ResourceValidationArgs, report: ReportViolation) -> None:
if args.resource_type != "aws:ec2/instance:Instance":
return
cfg: Dict[str, Any] = args.get_config()
allowed: List[str] = cfg.get("allowedTypes", [])
actual: str = args.props.get("instanceType", "")
if allowed and actual not in allowed:
report(f"instanceType {actual!r} is not in {allowed}.", args.urn)
approved_sizes = ResourceValidationPolicy(
name="approved-instance-types",
description="EC2 instances must use a finance-approved instance type.",
validate=instance_sizes,
enforcement_level=EnforcementLevel.ADVISORY,
config_schema=PolicyConfigSchema(
properties={"allowedTypes": {"type": "array", "items": {"type": "string"}}}))
{
"approved-instance-types": {
"enforcementLevel": "mandatory",
"allowedTypes": ["t3.small", "t3.medium", "m6i.large"]
}
}
With that file in place, promoting a rule is a pull request against a JSON document that a platform team owns, not a new version of a Python package that every consumer has to adopt. Keep the file in the same repository as the pipeline definition so the two move together.
Policies Worth Adopting First
The first advisory run over a real stack is always instructive, and it is almost always dominated by tagging. That is not because tagging is the most dangerous gap — it is because tags are the rule people forget most often, on the widest range of resource types.
Sequence the adoption to match. Ship the tagging rule first: it has the highest hit rate, the fix is mechanical, and nobody argues about whether a resource should have an owner. The tagging policy guide covers the awkward parts — which resource types actually accept a tags map, how to read the required set from configuration instead of hard-coding it, and how to avoid failing on resources that have no tag support at all.
Encryption comes second. It is a smaller set of resources, the rule is a straight property check, and the remediation is usually a one-line addition to a reusable component rather than a change to every stack. Network exposure comes third, and it is where you want to have read the walkthrough on writing a CrossGuard policy first, because ingress rules are nested lists and the property shapes are less obvious than a top-level boolean.
Identity rules come last, not because they matter least but because they are the hardest to express as a property check — an over-broad IAM policy document is a JSON string that needs parsing. The techniques for that live in enforcing IAM least privilege in Python IaC, and they are worth a separate rollout of their own.
Step-by-Step: Enforcing a Baseline
Start advisory, watch what would fail, then promote stable rules to mandatory. Run the pack locally, then wire it into CI so no stack merges without passing.
1. Scaffold the pack and pin its dependencies
# CLI: create the pack next to (not inside) the infrastructure project
pulumi policy new aws-python --dir ./policy
cd ./policy && python -m venv venv && ./venv/bin/pip install -r requirements.txt
The template writes a working __main__.py with one example rule. Delete the example, keep the structure, and split rules into a rules/ package as soon as you have more than three — a 400-line __main__.py is as unpleasant in a policy pack as it is in a Pulumi program.
2. Discover the real resource type tokens
Guessing type tokens is the most common source of rules that silently never fire. Print them once, from a rule that matches everything, and keep the list.
# __main__.py — a temporary discovery rule
# CLI: pulumi preview --policy-pack ./policy --stack dev 2>&1 | sort -u
from pulumi_policy import (
PolicyPack, ResourceValidationArgs, ResourceValidationPolicy,
ReportViolation, EnforcementLevel)
def dump_types(args: ResourceValidationArgs, report: ReportViolation) -> None:
print(f"{args.resource_type}\t{args.name}\t{sorted(args.props)[:6]}")
PolicyPack(
name="acme-baseline",
enforcement_level=EnforcementLevel.ADVISORY,
policies=[ResourceValidationPolicy(
name="type-discovery",
description="Temporary: prints every resource type the engine registers.",
validate=dump_types)])
The output shows exactly what to match on — aws:s3/bucketV2:BucketV2, not aws_s3_bucket, and kubernetes:apps/v1:Deployment for workloads managed through the Kubernetes provider. Remove the rule before committing; it is noise in a shared pipeline.
3. Write the rule advisory-first and run it against a real stack
# CLI: evaluate a stack against the pack, failing on mandatory violations
pulumi preview --policy-pack ./policy --stack prod
Read every advisory violation before promoting anything. Expect two categories: genuine findings, and false positives caused by property names that differ from the console. Fix the second category in the rule, and file the first as work.
4. Promote and enforce in the pipeline
Once green locally, register the pack in your pipeline so every preview enforces it. Group related rules (encryption, tagging, public-access) into one pack per baseline so consumers adopt them together.
# CLI: publish once, then enable per organisation so consumers need no local copy
pulumi policy publish acme
pulumi policy enable acme/acme-baseline latest --policy-group default
Publishing moves the pack from "a directory someone remembered to pass on the command line" to an organisation-level policy group that applies to every stack automatically. That is the end state you want; --policy-pack is for development.
Verification
Prove the pack both blocks bad resources and passes good ones — a rule that never fails is worthless, and one that always fails is unusable.
# CLI: a compliant stack passes; a deliberately bad one fails
pulumi preview --policy-pack ./policy # expect: no violations
Add unit tests that call each validate function with crafted args and assert the report, exactly as you would unit test a Pulumi program.
# tests/test_sizing.py — a rule test with no Pulumi engine involved
# CLI: pytest tests/ -q
from typing import Any, Dict, List
from rules.sizing import instance_sizes
class FakeArgs:
def __init__(self, resource_type: str, props: Dict[str, Any], cfg: Dict[str, Any]):
self.resource_type = resource_type
self.props = props
self.urn = "urn:pulumi:dev::demo::aws:ec2/instance:Instance::web"
self._cfg = cfg
def get_config(self) -> Dict[str, Any]:
return self._cfg
def test_rejects_unapproved_type() -> None:
seen: List[str] = []
args = FakeArgs("aws:ec2/instance:Instance", {"instanceType": "m5.24xlarge"},
{"allowedTypes": ["t3.small"]})
instance_sizes(args, lambda msg, urn=None: seen.append(msg))
assert "m5.24xlarge" in seen[0]
def test_ignores_other_resource_types() -> None:
seen: List[str] = []
args = FakeArgs("aws:s3/bucketV2:BucketV2", {}, {"allowedTypes": ["t3.small"]})
instance_sizes(args, lambda msg, urn=None: seen.append(msg))
assert seen == []
The second test is the one that saves you. A rule that reports on every resource type because the early return was dropped will pass a naive "does it catch the bad case" test and then block every deploy in the organisation. Keep a negative case for every rule.
Beyond unit tests, keep one deliberately non-compliant stack in a sandbox account and run the pack against it in CI. That catches the failure mode unit tests cannot: a rule that works against a hand-built props dictionary but never matches what the engine actually sends.
Make that second check assert on the exit code rather than on log text. A preview that trips a mandatory rule exits non-zero, so the sandbox job should invert the expectation and fail loudly when the bad stack passes:
# CLI: the sandbox stack MUST fail; a zero exit code means the pack regressed
if pulumi preview --policy-pack ./policy --stack sandbox-noncompliant; then
echo "policy pack no longer blocks the known-bad stack" >&2
exit 1
fi
Run both jobs on every change to the pack and on a nightly schedule. The nightly run is what catches drift in the other direction — a provider upgrade that renames a property or introduces a new resource version, which turns a working rule into a silent no-op without anyone touching the pack.
Troubleshooting
Rule never triggers — the resource_type string is wrong; print args.resource_type during a preview to get the exact token. The tokens are versioned with the provider, so aws:s3/bucket:Bucket and aws:s3/bucketV2:BucketV2 are different resources and a rule written for one is invisible to the other.
Property is always missing — you are matching the HCL/console name, not Pulumi's camelCase property; check the resource's input schema. server_side_encryption_configuration in the Python program becomes serverSideEncryptionConfiguration by the time the analyzer sees it, because the wire format is the provider's schema, not Python's naming convention.
Pack blocks everything — a mandatory rule is too strict or has a bug; drop it to advisory, fix, and re-promote. The failure looks like this, and the URN in the block tells you which resource tripped it:
# CLI: pulumi preview --policy-pack ./policy --stack prod
Policy Violations:
[mandatory] acme-baseline v0.0.1 (s3-encryption-required)
urn:pulumi:prod::web::aws:s3/bucketV2:BucketV2::app-assets
S3 buckets must define server-side encryption.
error: preview failed
ModuleNotFoundError: No module named 'pulumi_policy' — the analyzer is running under a different interpreter than the one you installed into. The pack uses its own virtual environment resolved from the pack directory, not the infrastructure project's. Re-run pip install -r requirements.txt from inside ./policy.
AttributeError: 'NoneType' object has no attribute 'get' — a nested property you assumed was present is absent on some resources. args.props.get("tags") returns None for a resource with no tags, and .get("owner") on that fails. Default the lookup instead: args.props.get("tags") or {}.
Violations appear against the stack, not the resource — the rule called report(message) without the URN. Pass args.urn as the second positional argument so the diagnostic attaches to the offending resource and shows up next to it in the preview output.
FAQ
Is CrossGuard AWS-only?
No. Policies match on resource type, so you can write rules for any Pulumi provider — AWS, GCP, Kubernetes, or a custom dynamic provider. The type token format is identical across providers, which means one pack can carry cloud rules and Kubernetes rules side by side.
Can policies auto-remediate?
Resource validation policies report violations; they do not mutate resources. Newer releases of pulumi-policy add remediation policies, which return a modified property bag instead of a message, but they run before validation and are best reserved for mechanical defaults such as injecting a missing tag. Anything requiring judgement belongs in the program itself or a separate reconciliation job.
How does this relate to Checkov?
Checkov scans static configuration; CrossGuard evaluates Pulumi's actual planned graph at preview time, so it sees computed values Checkov cannot. Teams frequently run both — Checkov over synthesised Terraform and CI-time YAML, CrossGuard over Pulumi previews — and share the rule catalogue between them by writing custom Checkov policies that mirror the CrossGuard set.
Do policy packs slow down previews?
Marginally. Each resource crosses a gRPC boundary once and the rules run in a single Python process, so the overhead is a few milliseconds per resource for typical property checks. What does hurt is a rule that makes a network call — never query a pricing or inventory API from inside validate, because the cost is paid on every resource of every preview.
Can a rule read Pulumi stack configuration?
Not directly; the analyzer sees resources, not the stack's config bag. Pass what a rule needs through the policy pack's own configuration with --policy-pack-config, or have the program surface the relevant value as a resource property or tag the rule can inspect. Keeping the two configuration systems separate is deliberate — it stops a stack from configuring its way out of a policy.
How do I exempt a resource that legitimately breaks a rule?
Add the exemption to the rule, not to the resource. A config_schema field such as exemptUrns or a check for a specific tag keeps the exception visible in the policy repository, where it can be reviewed and expired. Avoid the pattern of disabling the whole pack for one stack; that trades one visible exception for an invisible blanket one.
Related
- Writing Pulumi CrossGuard Policies in Python — a hands-on walkthrough of a resource validation policy from empty directory to failing preview.
- Enforcing Tagging Policies with Pulumi CrossGuard — a concrete, widely-needed policy you can adopt today.
- Security & Compliance Basics — the broader compliance context policy packs fit into.
- Pulumi Patterns & Provider Management — the parent section covering providers, components, and stack design.