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.

Required tags Required tags: Tag baseline with 4 facets. Tag baseline owner team email env dev/stg/prod cost-center GL code Level mandatory
Every taggable resource must carry the full required set or the preview fails.

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

Prerequisites Prerequisites: layered from Python interface / API down to Cloud runtime. Python interface / API Typed resource model Provider plugin State backend Cloud runtime
Prerequisites: the stack from the Python interface down to the cloud runtime.
  • pulumi-policy installed 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.

What ships inside the policy pack directory What ships inside the policy pack directory: layered from PulumiPolicy.yaml down to tests/. PulumiPolicy.yaml runtime: python, entry point requirements.txt pins pulumi-policy for the pack venv __main__.py constructs the PolicyPack object tags.py the validate functions and the tag schema tests/ pytest calls validate directly, no cloud
The pack is a separate Python project with its own dependencies, not part of the stack program.
# 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.

Tag enforcement Tag enforcement: resource planned then has tags? then compare to set then report missing resource planned has tags? compare to set report missing
Each taggable resource is checked against the required tag set during preview.
# 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.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# 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.

Getting to mandatory without blocking every team Getting to mandatory without blocking every team: Advisory run then Inventory gaps then Backfill tags then Flip to mandatory Advisory run no failures Inventory gaps count by stack Backfill tags owning teams Flip to mandatory CI enforces
Enforcement level is the dial that turns a reporting tool into a gate.

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

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks defaultTags watch this boundary tags watch this boundary Owner watch this boundary Edge Cases watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

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.

Tag shapes the rule must handle Tag shapes the rule must handle: comparison across Property, Shape, Rule branch. Resource Property Shape Rule branch aws:s3 Bucket tags map of string Default path aws:autoscaling Group tags list of maps Read key/value pairs gcp:storage Bucket labels map, lowercase Separate key set azure ResourceGroup tags map of string Default path
One 'tags' check does not fit every provider: the property name and the shape both vary.

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.