Enforce Tagging Policies with Pulumi CrossGuard
Cost allocation and ownership fall apart without consistent tags. This guide, part of Pulumi policy as code under Pulumi patterns and provider management, writes a CrossGuard policy that requires every taggable resource to carry a mandatory set of tags before a deploy is allowed.
Context
Finance wants every resource tagged with owner, env, and cost-center. Enforcing that with a CrossGuard policy means untagged resources fail preview, so the tag set is guaranteed rather than hoped for.
Enforcing at preview rather than auditing after the fact changes what the rule can promise. An audit script that sweeps the account tomorrow tells you which resources are already untagged and already billing to nobody; a policy that runs inside pulumi preview refuses to create the resource in the first place, so the gap never exists. The cost of that guarantee is that the check only sees what a preview plans — it knows the inputs your program registered, not the state of resources nobody is currently updating.
Decide up front what "every resource" means, because the answer drives the whole rule. Most estates land on: every resource that supports tags, in stacks that create billable infrastructure, excluding resources whose tags are managed elsewhere. Each of those exclusions has to be expressible in code, which is why the required set and the exemption list belong in the pack rather than in a wiki page nobody reads.
Prerequisites
pulumi-policyinstalled and a working policy pack skeleton- A list of resource types that support tags in your stacks
- Agreement on the exact required tag keys
# CLI: confirm the pack runs before adding the tag rule
pulumi preview --policy-pack ./policy
A policy pack is a standalone Python project with its own manifest and its own dependencies. The Pulumi CLI creates a virtual environment for it on first run, installs requirements.txt, and imports the entry point named in PulumiPolicy.yaml. Getting either file wrong produces a pack that never loads, and the CLI reports it as a plugin error rather than a policy failure.
# policy/PulumiPolicy.yaml — the pack manifest the CLI reads first
name: tag-baseline
runtime: python
description: Mandatory tag keys for cost allocation and ownership.
# policy/requirements.txt — the pack has its own dependency graph
pulumi>=3.100.0,<4.0.0
pulumi-policy>=1.12.0,<2.0.0
Implementation
Match any resource whose props include a tags map and report the missing keys. Keeping the required set in one constant makes the rule easy to audit.
# policy/tags.py — require a fixed tag set on taggable resources
# CLI: pulumi preview --policy-pack ./policy
from pulumi_policy import ResourceValidationPolicy, ReportViolation
REQUIRED = {"owner", "env", "cost-center"}
def require_tags(args, report: ReportViolation) -> None:
props = args.props
if "tags" not in props: # resource is not taggable — skip
return
present = set((props.get("tags") or {}).keys())
missing = REQUIRED - present
if missing:
report(f"Missing required tags: {', '.join(sorted(missing))}.")
tag_policy = ResourceValidationPolicy(
name="required-tags",
description="Taggable resources must carry owner, env, cost-center.",
validate=require_tags)
Move the required set into policy configuration
Hard-coding REQUIRED is the right first version and the wrong long-term one: finance adds a key, and now the pack needs a code change and a republish to match. A config schema lets the same pack take its tag list from the run, so the policy code stops changing when the policy of record does.
# policy/tags.py — required keys supplied per run, with a schema and a default
# CLI: pulumi preview --policy-pack ./policy --policy-pack-config ./policy/config.json
from __future__ import annotations
from typing import Any, Dict, List
from pulumi_policy import (
EnforcementLevel, PolicyConfigSchema, ReportViolation,
ResourceValidationArgs, ResourceValidationPolicy)
DEFAULT_REQUIRED: List[str] = ["owner", "env", "cost-center"]
def require_tags(args: ResourceValidationArgs, report: ReportViolation) -> None:
props: Dict[str, Any] = args.props
if "tags" not in props:
return
config = args.get_config()
required = set(config.get("requiredTags", DEFAULT_REQUIRED))
present = {k.lower() for k in (props.get("tags") or {})}
missing = sorted(required - present)
if missing:
report(f"{args.name} ({args.resource_type}) is missing "
f"required tags: {', '.join(missing)}.")
tag_policy = ResourceValidationPolicy(
name="required-tags",
description="Taggable resources must carry the configured tag keys.",
enforcement_level=EnforcementLevel.MANDATORY,
config_schema=PolicyConfigSchema(
properties={"requiredTags": {"type": "array", "items": {"type": "string"}}},
),
validate=require_tags)
{
"required-tags": {
"requiredTags": ["owner", "env", "cost-center", "data-class"]
}
}
The config file is keyed by policy name, so one file can carry settings for every rule in the pack. Publishing the pack to a Pulumi organization moves that configuration server-side, where it is set once per policy group instead of being passed on the command line by whoever happens to run the preview.
Validate values, not only keys
A resource tagged owner=tbd passes a key-only rule and tells you nothing. Values are where tagging policy earns its keep, and they are also where a policy starts rejecting changes for reasons a developer cannot guess, so every violation message must name the key, the value it got, and the shape it wanted.
# policy/tag_values.py — constrain the values, with actionable messages
# CLI: pulumi preview --policy-pack ./policy
from __future__ import annotations
import re
from typing import Any, Dict
from pulumi_policy import ReportViolation, ResourceValidationArgs, ResourceValidationPolicy
ENVIRONMENTS = {"dev", "staging", "prod"}
COST_CENTER = re.compile(r"^CC-\d{4}$")
OWNER = re.compile(r"^[^@\s]+@acme\.example$")
def validate_tag_values(args: ResourceValidationArgs, report: ReportViolation) -> None:
tags: Dict[str, Any] = (args.props or {}).get("tags") or {}
env = tags.get("env")
if env is not None and env not in ENVIRONMENTS:
report(f"env='{env}' is not one of {sorted(ENVIRONMENTS)}.")
cc = tags.get("cost-center")
if cc is not None and not COST_CENTER.match(str(cc)):
report(f"cost-center='{cc}' must match CC-#### (four digits).")
owner = tags.get("owner")
if owner is not None and not OWNER.match(str(owner)):
report(f"owner='{owner}' must be an acme.example address.")
value_policy = ResourceValidationPolicy(
name="tag-values",
description="Tag values must match the agreed vocabulary.",
validate=validate_tag_values)
Note what the rule does not do: it never reports a missing key. Splitting presence from validity keeps the two failures independently promotable — you can make presence mandatory while values are still advisory, and read the two counts separately while a backfill is in flight.
Catch what a per-resource rule cannot see
ResourceValidationPolicy fires once per planned resource, which makes it blind to relationships. If the requirement is "every bucket in a stack shares one cost-center", or "a stack must not mix env=prod and env=dev resources", you need StackValidationPolicy, which receives the whole planned set after every resource has been evaluated.
# policy/stack_rules.py — one cost-centre per stack
# CLI: pulumi preview --policy-pack ./policy
from __future__ import annotations
from typing import Set
from pulumi_policy import ReportViolation, StackValidationArgs, StackValidationPolicy
def one_cost_center(args: StackValidationArgs, report: ReportViolation) -> None:
centers: Set[str] = set()
for resource in args.resources:
tags = (resource.props or {}).get("tags") or {}
if isinstance(tags, dict) and tags.get("cost-center"):
centers.add(str(tags["cost-center"]))
if len(centers) > 1:
report(f"Stack mixes cost centres: {', '.join(sorted(centers))}. "
"Split it, or agree one owner for the shared resources.")
stack_policy = StackValidationPolicy(
name="single-cost-center",
description="A stack bills to exactly one cost centre.",
validate=one_cost_center)
Stack policies run after resource policies, so a stack rule never fires on a preview that has already failed a mandatory resource rule. That ordering is useful: keep cheap, specific checks at the resource level and reserve stack rules for the questions that genuinely need the whole picture.
Verification
Deploy-preview a stack with a deliberately untagged bucket and confirm it fails listing the missing keys, then add the tags and confirm a clean run.
# CLI: expect a violation naming the missing tags
pulumi preview --policy-pack ./policy --stack dev
A mandatory violation stops the preview and prints the policy name, the enforcement level, and your message under the offending resource:
Diagnostics:
aws:s3/bucket:Bucket (reports):
mandatory: [required-tags] Taggable resources must carry the configured tag keys.
reports (aws:s3/bucket:Bucket) is missing required tags: cost-center, owner.
error: preview failed
Do not stop at the manual check. The validate functions are ordinary Python, so unit-test them with a stub args and a report that appends to a list — no cloud calls, no stack, and a test that runs in milliseconds on every commit.
# policy/tests/test_tags.py — call validate directly
# CLI: pytest policy/tests
from __future__ import annotations
from types import SimpleNamespace
from typing import List
from policy.tags import require_tags
def _args(tags: dict) -> SimpleNamespace:
return SimpleNamespace(
name="reports", resource_type="aws:s3/bucket:Bucket",
props={"tags": tags}, get_config=lambda: {})
def test_reports_only_missing_keys() -> None:
found: List[str] = []
require_tags(_args({"owner": "a@acme.example", "env": "prod"}), found.append)
assert len(found) == 1 and "cost-center" in found[0]
def test_fully_tagged_resource_is_silent() -> None:
found: List[str] = []
require_tags(_args({"owner": "a@acme.example", "env": "prod",
"cost-center": "CC-0042"}), found.append)
assert found == []
Rolling the Rule Out Against an Existing Estate
Switching a tagging rule to mandatory on a mature estate breaks every pipeline at once, and the team that owns the pack absorbs the blame for work it does not own. Stage it instead, and let the enforcement level carry the stage.
Start with EnforcementLevel.ADVISORY on the whole pack. Advisory violations print during preview and update, exit zero, and cost nothing but log noise — which is exactly what you need to answer "how bad is it?" with numbers rather than guesses. Run it across every stack in CI for a week and collect the reported resource names per stack; that list is the backfill work item, and it belongs to the stack owners, not to whoever wrote the policy.
Backfill in the program, not by hand in the console. A tag added through the cloud console is invisible to Pulumi's desired state, so the next update strips it and the violation returns. For AWS stacks, default_tags on the provider is the fastest legitimate backfill for organization-wide keys like env, leaving only genuinely per-resource keys such as owner to be set inline.
Flip to mandatory per stack rather than globally. pulumi preview --policy-pack ./policy takes the pack from a local path, so a stack can adopt the stricter version by changing one CI variable, and a stack that is still mid-backfill keeps running the advisory copy. Once every stack is on mandatory, publish the pack to the organization and delete the local path entirely so nobody can quietly stay behind.
Gotchas & Edge Cases
Default tags hide gaps. If you set provider-level defaultTags, resources may inherit them and the rule sees them as present — decide whether that satisfies the policy.
Non-taggable resources. Skipping resources without a tags key avoids false positives, but some resources tag via a sub-block; handle those explicitly.
Case sensitivity. Owner and owner are different keys; normalise before comparing if your org is inconsistent.
tags is not always a map.
An aws:autoscaling/group:Group carries tags as a list of {key, value, propagateAtLaunch} objects, so set(props["tags"].keys()) raises AttributeError: 'list' object has no attribute 'keys' and takes the whole preview down with it. Branch on the shape before reading keys, and treat an unexpected type as a violation rather than an exception.
GCP calls them labels, and the rules differ.
gcp:storage/bucket:Bucket and most GCP resources use labels, not tags, and Google restricts keys and values to lowercase letters, numbers, hyphens and underscores. A rule that demands cost-center=CC-0042 is unsatisfiable on GCP because of the uppercase; either define a per-provider vocabulary or normalise to lowercase everywhere and accept it.
Unknown values at preview.
A tag whose value comes from another resource's output is not yet resolved during a preview of a new stack, and arrives as None. Reporting that as a violation blocks legitimate changes; skipping it silently lets an empty value through on the real update. Fail closed for mandatory keys, and say so in the message so the author knows to hard-code the value or move it out of a tag.
Operational Notes
A pack that lives in one repository and is passed by path works until a second team needs it. Publishing moves it to the organization, where it is versioned, configured centrally, and applied to whole sets of stacks without anyone remembering a CLI flag.
# CLI: publish a version, then enable it for the organization's default policy group
pulumi policy publish acme
pulumi policy enable acme/tag-baseline latest --policy-group production
pulumi policy ls acme
# State implication: enabling a pack applies it to every stack in the group on the
# NEXT update — it does not retroactively evaluate resources already in state.
Versions are immutable once published, which is the property that makes rollback trivial: pin a policy group to 0.0.3, and a bad rule in 0.0.4 cannot reach production until someone enables it deliberately. Keep the pack's version in the same review flow as the stacks it governs, because a tightened rule is a breaking change for every stack in the group.
Exemptions need a mechanism before they need a policy. Somebody will have a legitimately untaggable resource — a provider-managed default VPC, a resource whose tags are owned by another tool — and if the only escape hatch is disabling the pack, that is what will happen. Two mechanisms are worth having: a URN prefix allowlist read from policy config, and a documented policy-exempt tag whose presence is itself reported as an advisory so exemptions stay visible rather than becoming permanent.
Watch the runtime cost. Every policy runs against every planned resource on every preview, and a pack that compiles a regex or reads a file inside validate pays that cost thousands of times in a large stack. Hoist the constants to module scope, as the value rules above do, and keep the per-resource work to dictionary lookups and set arithmetic. A pack that adds seconds to every preview is a pack teams route around.
Finally, treat the required tag set as data the business owns. The keys exist because finance allocates cost and security assigns ownership; when they change the taxonomy, the pack's config should change with it in one commit, not in a scattered set of program edits. Static scanning tools cover the same ground from the other direction — see scanning Python IaC with Checkov — but only a policy running inside preview can refuse the deployment.
FAQ
Can I require tag values, not just keys?
Yes — extend the rule to validate the value (e.g. env in a known set) and report when it is missing or invalid.
Does this cover existing resources?
Policies evaluate what a preview plans, so pre-existing untagged resources surface the next time they are updated. Pair with a one-off audit for full coverage.
Where should the required set live?
In one constant in the pack, ideally sourced from the same place your finance team maintains it, so the rule and the policy of record never diverge.
Why does my rule never fire?
The most common cause is a resource type token that does not match. Tokens are case- and path-sensitive (aws:s3/bucket:Bucket, not aws:s3:Bucket), and a typo silently matches nothing. Print args.resource_type from an advisory policy for one run to see the exact tokens a preview produces.
Do provider-level default tags satisfy the policy?
Verify rather than assume: the analyzer sees the inputs your program registered, and provider-applied defaults may not be part of them. Add a temporary advisory rule that reports the tags map it actually receives for one representative resource, then decide whether the pack measures declared intent or final state.
Can a policy fix the tags instead of failing the deployment?
Recent pulumi-policy releases add remediation policies that return corrected properties during preview. They are useful for mechanical defaults such as stamping env from the stack name, and a poor fit for anything requiring judgement — a remediation that invents an owner value is worse than the violation it silences.
Related
- Writing Pulumi CrossGuard Policies in Python — the foundational policy walkthrough this builds on.
- Pulumi Policy as Code with CrossGuard — the parent topic on policy packs.
- Scanning Python IaC with Checkov — complementary static scanning of configuration.