Enforcing Tagging and Sizing Policies with OPA for PostGIS

You need a terraform plan that provisions a PostGIS RDS instance to be rejected in CI whenever it omits the owner, data-classification, or cost-center tags, or whenever it requests an instance class larger than your approved ceiling. This is the concrete build behind Policy as Code for Spatial Resources, part of the broader CI/CD, Drift Detection & Policy Governance discipline — writing the Rego, generating the plan JSON, wiring Conftest into the pipeline so it fails closed, and handling the legitimate exemptions that would otherwise tempt someone to disable the rule entirely.

Symptom Identification and Triage

The failures this guardrail prevents are quiet ones. An untagged PostGIS instance does not error — it provisions cleanly and then disappears from FinOps chargeback because no cost-center tag routes its spend, and it becomes unownable during an incident because no owner tag says who to page. An oversized instance is worse: someone types db.r6g.8xlarge into a dev module, it applies, and the bill arrives a month later. Before writing policy, confirm the gap against observable signals:

  • A running aws_db_instance with no owner / data-classification / cost-center tags. Query it with aws rds list-tags-for-resource --resource-name <arn> and diff against the required set. Untagged data-tier resources are the highest-priority finding because they break both chargeback and incident routing.
  • An instance class outside the approved list. aws rds describe-db-instances --query 'DBInstances[].DBInstanceClass' returns a class your policy never sanctioned — the signal that no sizing gate ran at plan time.
  • A plan that passed CI but produced a non-compliant instance. If the resource exists yet no policy job failed, the rule either never matched this resource type or read the wrong plan representation. Treat a green pipeline over a non-compliant resource as a policy-coverage bug, not a one-off.

Use the tree below to route the finding before you touch any Rego.

Triage: why did a non-compliant PostGIS instance get through? Top-down decision tree. The finding of a non-compliant PostGIS instance leads to inspecting the terraform plan JSON resource_changes entry for the aws_db_instance. A decision node asks what is wrong. Three branches: missing required tags routes to the tagging Rego rule, an instance class outside the approved set routes to the sizing Rego rule, and a resource the policy never evaluated routes to a coverage fix that adds a fixture and a not-matched assertion. Non-compliant PostGIS instance found Inspect plan JSON resource_changes for the aws_db_instance What is wrong? Missing tags → tagging Rego rule owner · class · cost-center Class too large → sizing Rego rule approved-set ceiling Never matched → coverage fix add plan fixture zero evaluations

Prerequisites and Environment Assumptions

This build targets an AWS estate where PostGIS runs on RDS, provisioned with Terraform. You will need:

  • Terraform >= 1.10.0 with the AWS provider pinned. Unpinned providers change plan JSON shapes between runs, which is a leading cause of a policy that passes locally and fails in CI:

    terraform {
      required_version = ">= 1.10.0"
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 5.60"
        }
      }
    }
  • Conftest 0.56.0 (which bundles the OPA Rego engine) available on the CI runner. Pin the binary version so rule evaluation is reproducible.

  • A CI runner with read-only access to the plan artifact. The policy job must not hold a mutating AWS role — it inspects JSON and nothing more. The apply job, which does hold the role, runs only after the policy job reports success, consistent with the least-privilege model in IAM Role Mapping for GIS.

  • A parameter group for the PostGIS instance if you tune memory. Any memory parameter here is set in AWS integer block units, not percentages — see the note in the sizing step below.

Step-by-Step Remediation

Write the rule, prove it against a plan, then wire it into CI so it fails closed.

  1. Define the required tags and approved classes as data. Keeping the ceiling in a data document lets dev and prod share rule logic while differing on limits. Create env/prod/limits.json:

    {
      "required_tags": ["owner", "data-classification", "cost-center"],
      "approved_db_classes": [
        "db.t4g.medium",
        "db.r6g.large",
        "db.r6g.xlarge",
        "db.r6g.2xlarge"
      ]
    }

    Separating data from logic is what keeps the guardrail honest across environments — nobody can weaken prod without editing a reviewed file, and the dev ceiling is just a different document feeding identical Rego.

  2. Write the Rego rule. It iterates resource_changes — the proposed change set — rather than the resulting state, so the verdict reflects exactly what this plan will do to the PostGIS instance:

    # policy/postgis.rego
    package terraform.postgis
    
    import future.keywords.contains
    import future.keywords.if
    import future.keywords.in
    
    # Non-deleting aws_db_instance changes in this plan.
    pg_instances[r] {
        some r in input.resource_changes
        r.type == "aws_db_instance"
        r.change.actions[_] != "delete"
    }
    
    # Fail on any missing required tag.
    deny contains msg if {
        some r in pg_instances
        tags := object.get(r.change.after, "tags", {})
        missing := {t | some t in data.required_tags; not tags[t]}
        count(missing) > 0
        msg := sprintf("PostGIS '%s' missing required tags: %v", [r.name, missing])
    }
    
    # Fail on an instance class outside the approved set.
    deny contains msg if {
        some r in pg_instances
        class := r.change.after.instance_class
        not class in {c | some c in data.approved_db_classes}
        msg := sprintf("PostGIS '%s' uses unapproved class %q", [r.name, class])
    }

    The sizing rule enumerates the approved set and denies anything outside it, so a newly released, larger instance family is blocked by default until someone deliberately adds it — a policy that ages well instead of leaking with every AWS release. If this instance also sets memory-oriented RDS parameters, express them in AWS block units, described in the note below.

    Note: RDS parameter-group memory values use AWS integer units of 8 kB blocks, never percentage strings. For a db.r6g.large (16 GiB) PostGIS instance, shared_buffers = 524288 means 524288 × 8 kB ≈ 4 GiB, roughly a quarter of RAM. A policy that checks shared_buffers must compare integer block counts, so shared_buffers = "25%" is both invalid to RDS and unmatchable by the rule.

  3. Generate the plan JSON. Conftest evaluates JSON, so convert the binary plan:

    terraform plan -out=plan.bin
    terraform show -json plan.bin > plan.json

    Evaluating this JSON — the proposed change — is what lets you reject the PostGIS instance before it is ever created, rather than discovering the violation after it is running and billing.

  4. Wire Conftest into CI, failing closed. The job passes the per-environment limits as data and treats any deny or any engine error as a hard stop:

    #!/usr/bin/env bash
    set -euo pipefail
    
    # Guard against a vacuous pass on an empty/truncated plan.
    count=$(jq '.resource_changes | length' plan.json)
    if [ "$count" -eq 0 ]; then
      echo "plan.json has no resource_changes — refusing to pass vacuously" >&2
      exit 1
    fi
    
    conftest test plan.json \
      --policy policy/ \
      --data "env/${DEPLOY_ENV}/limits.json" \
      --all-namespaces
    # conftest exits non-zero on any deny; set -e stops the pipeline before apply.
  5. Handle legitimate exemptions as expiring data. When a resource genuinely needs an unapproved class, encode a time-bounded waiver rather than disabling the rule. Add an exemptions.json keyed by resource address with a mandatory expires date, and make the Rego skip a resource only when a non-expired waiver exists — an exemption without an expiry is a silent, permanent policy rollback.

Verification

Confirm the guardrail actually blocks and actually permits, in both directions:

  1. Prove it denies. Craft a plan with an untagged db.r6g.8xlarge PostGIS instance and run the CI script. Conftest must exit non-zero and print both the missing-tags and unapproved-class messages naming the resource.

    conftest test plan.json --policy policy/ --data env/prod/limits.json
    # Expect: FAIL - plan.json - PostGIS 'analytics' missing required tags: {...}
    #         FAIL - plan.json - PostGIS 'analytics' uses unapproved class "db.r6g.8xlarge"
  2. Prove it permits. Add the three tags and change the class to db.r6g.xlarge; the same command must exit 0 with no failures. A rule that only ever denies is as broken as one that only ever allows.

  3. Prove coverage. Run the suite against a fixture plan for the PostGIS resource type and assert the rule evaluated it — a green result on a resource the rule never matched is the coverage bug from triage, not a pass.

  4. Confirm live tags. After apply, aws rds list-tags-for-resource --resource-name <arn> must return owner, data-classification, and cost-center, closing the loop from plan-time policy to running resource.

Preventing Recurrence

  • Run the policy on every pull request, fail closed. Make the policy job a required check so no PostGIS change merges without passing, and ensure engine errors or empty plan JSON stop the pipeline rather than waving it through.
  • Test the rules against fixtures. Keep a plan fixture per geospatial resource type and assert each rule evaluates the resources it targets, so a switch from aws_db_instance to aws_rds_cluster for Aurora PostGIS cannot silently escape the sizing rule.
  • Expire every exemption. Fail the run when a waiver is past its expires date, so exceptions decay instead of accumulating into permanent holes.
  • Pair with drift detection. Plan-time policy governs intent; a scheduled sweep from Drift Detection and Remediation for Geospatial Estates catches a class or tag changed by a console edit after apply.
  • Align tags with access and cost. Keep the required-tag set the same tags IAM Role Mapping for GIS keys access on and Cost Estimation Frameworks uses for chargeback, so one tag policy serves security, FinOps, and incident routing at once.

Frequently Asked Questions

Why evaluate the plan JSON instead of the running RDS instance?

Because the plan describes the change before it happens, so a non-compliant PostGIS instance is rejected at the pull request rather than discovered after it is provisioned and billing. Scanning the live instance is still useful, but that is drift detection — a separate, complementary control that runs after apply.

Should I list forbidden instance classes or approved ones?

List the approved set and deny everything else. A deny-list of forbidden classes silently permits every newly released, larger instance family the moment AWS ships it, whereas an approved-set ceiling blocks the unknown until someone deliberately adds it after review.

The instance is tagged in the console but the plan still fails — why?

The policy reads the plan’s change.after.tags, which reflects the Terraform configuration, not a manual console tag. Add the tags in the module (or a default_tags block on the provider) so the plan carries them; a console-only tag is exactly the drift the guardrail is meant to prevent.

How do I express a PostGIS memory parameter in policy?

Use AWS integer block units of 8 kB, not percentages. shared_buffers = 524288 is 524288 × 8 kB ≈ 4 GiB; a rule comparing it must compare integer block counts. A value like "25%" is invalid to RDS and cannot be matched by a numeric rule.