Policy as Code for Spatial Resources
Guardrails that live in a wiki are guardrails that get ignored the moment a raster bucket ships publicly at 2 a.m. Policy as code turns your organization’s rules for geospatial infrastructure — mandatory ownership tags, PostGIS instance-class ceilings, encryption on raster stores, a hard ban on public tile buckets — into machine-evaluated checks that run before a plan is ever applied. Within the broader discipline of CI/CD, Drift Detection & Policy Governance, policy enforcement is the gate that sits between a green plan and a live resource, and it works hand in glove with Pipeline Orchestration for Spatial Deployments, which decides when the check runs, and Drift Detection and Remediation for Geospatial Estates, which catches the rules that break after apply. This guide builds a production-grade policy layer for spatial infrastructure using Open Policy Agent with Conftest, HashiCorp Sentinel, and Checkov — evaluating the plan JSON in CI so a non-compliant PostGIS cluster or exposed tile bucket is rejected at the pull request, not discovered in a breach report.
Environment Parity and Configuration Drift Mitigation
The value of a policy layer is that every environment answers to the same rule set. A guardrail applied only to production is worthless, because the resource that leaks is almost always the one a developer stood up in a sandbox account and never tore down. Bind the same policy bundle to development, staging, and production pipelines from a single versioned source, and let the rule parameters — not the rule logic — vary per tier. A development PostGIS instance may be permitted up to db.r6g.large while production caps at db.r6g.4xlarge; both tiers run the identical sizing.rego module, differing only in a data document that supplies the ceiling. This keeps parity honest: nobody can weaken a rule for one environment without editing shared, reviewed code.
Policy as code also freezes the definition of “compliant” at a commit hash. When the mandatory-tag list or the approved-instance-class set lives in a repository next to the infrastructure it governs, a plan that passed six months ago can be replayed and will pass or fail against exactly the policy version that was active then. That reproducibility matters for geospatial estates because their resource graphs are deep — a single tile-serving stack touches buckets, CloudFront distributions, RDS parameter groups, and security groups — and an unversioned, hand-maintained checklist drifts out of sync with reality far faster than the infrastructure does. The policy bundle is an artifact: pin it, tag it, and promote it through the same release process as the modules it inspects.
The subtle drift risk is the rule that silently stops matching. A Rego rule that inspects aws_s3_bucket resources does nothing when a new module provisions tiles through aws_s3_bucket_acl and aws_s3_bucket_public_access_block as separate resources — the wildcard the author assumed would “catch everything” quietly matched nothing. Parity therefore includes coverage parity: the policy suite must be tested against representative plan fixtures for every geospatial resource type the platform actually deploys, so a new PostGIS engine version or a new raster-bucket pattern cannot slip past a rule that was written for last year’s resource shape.
CI/CD Validation and Operational Guardrails
Policy evaluation belongs at the pull-request stage, before any credential capable of mutating cloud resources is in scope. The pipeline runs terraform plan -out=plan.bin, converts it to machine-readable JSON with terraform show -json plan.bin > plan.json, and hands that JSON to the policy engine. Evaluating the plan — the proposed change — rather than the live state is the entire point: you are stopping the non-compliant resource before it exists, which is cheaper and safer than detecting it after provisioning. This is also why policy evaluation and drift detection are complementary rather than redundant; the plan gate governs intent, while a scheduled Drift Detection and Remediation for Geospatial Estates sweep governs the reality that diverges after apply.
The gate must fail closed. If the policy engine errors, the plan JSON is empty, or the rule bundle fails to load, the pipeline stops with a non-zero exit rather than waving the change through — an exception path that defaults to “allow” is not a guardrail. Wire the exit code so that any deny, any warning promoted to a hard failure, and any engine crash blocks the merge. Pair the policy stage with a pre-apply cost check from a Cost Estimation Frameworks integration, because sizing policy and cost policy overlap: an oversized PostGIS instance or an unbounded raster-bucket lifecycle is both a governance violation and a budget event, and catching both in one plan-JSON pass keeps the feedback loop tight.
Least-privilege wiring makes the gate trustworthy. The runner that evaluates policy needs only read access to the plan artifact; the runner that applies needs the mutating role, and it should refuse to start until the policy job has reported success. Because policy rules frequently assert identity constraints — that a bucket policy names a specific role, that a resource carries a data-classification tag consumed downstream — the rule authors need to understand the identity model documented in IAM Role Mapping for GIS; a tagging policy is only enforceable if the tags it demands are the same tags the access policies key off.
Resource Architecture and Service Integration
A spatial policy suite is organized by the resource classes that carry the most risk. The highest-value rules cluster around four resource families. First, object storage: raster and vector tile buckets provisioned under Object Storage for Raster/Vector must enforce encryption at rest and block all public access — a public tile bucket is the single most common way a geospatial platform leaks proprietary imagery. Second, the data tier: PostGIS instances provisioned via PostGIS Cluster Provisioning are constrained to an approved instance-class list and required to run with storage encryption and backups enabled. Third, tagging: every taggable resource must carry owner, data-classification, and cost-center, because those tags drive access control, incident routing, and FinOps chargeback. Fourth, spatial correctness: parameters like a default coordinate reference system or a projection SRID can be asserted so a tile pipeline cannot ship pinned to the wrong CRS.
The three engines occupy different niches, and mature estates use more than one. Open Policy Agent with Conftest is engine-agnostic — it evaluates any JSON, so the same Rego runs against a Terraform plan today and a Kubernetes manifest for a GeoServer deployment tomorrow, which is why it is the workhorse for cross-tool spatial platforms. HashiCorp Sentinel is the native fit when you run Terraform Cloud or Enterprise, because it hooks the run lifecycle directly and can read the plan, config, and state representations without a manual JSON export. Checkov brings a large library of prebuilt checks for cloud misconfiguration — public buckets, unencrypted volumes, open security groups — so it is the fastest way to cover the generic baseline while you write bespoke Rego for the geospatial-specific rules that no off-the-shelf check knows about.
Policy rules also read the resource graph the way downstream tiers do. A rule that verifies a raster bucket blocks public access is inspecting the same aws_s3_bucket_public_access_block resource that a security review would; a rule enforcing PostGIS encryption reads the same storage_encrypted attribute the data-tier module sets. Keeping the policy suite aligned with the actual module interfaces — rather than an idealized picture of them — is what prevents the coverage gaps described earlier, and it is why the rule authors and the module authors must review each other’s changes.
Runnable Configuration
The example below is a complete, pinned OPA/Conftest guardrail for spatial resources, plus the CI wiring that produces the plan JSON and evaluates it. The Rego enforces three rules against a Terraform plan: mandatory tags on every taggable resource, an approved instance-class ceiling for PostGIS RDS, and a hard ban on public raster buckets.
# policy/spatial.rego — evaluated by Conftest against a terraform plan JSON
package terraform.spatial
import future.keywords.contains
import future.keywords.if
import future.keywords.in
# Tags every taggable spatial resource must carry.
required_tags := {"owner", "data-classification", "cost-center"}
# PostGIS instance classes approved for any environment. The per-env ceiling
# is applied in CI by passing a stricter list via --data; this is the org max.
approved_db_classes := {
"db.t4g.medium", "db.r6g.large", "db.r6g.xlarge",
"db.r6g.2xlarge", "db.r6g.4xlarge",
}
# Resource types that must be tagged.
taggable := {"aws_db_instance", "aws_s3_bucket", "aws_instance", "aws_cloudfront_distribution"}
# Iterate the planned resources rather than the live state.
resources[r] {
some r in input.resource_changes
r.change.actions[_] != "delete"
}
# Rule 1 — mandatory tags on geospatial resources.
deny contains msg if {
some r in resources
r.type in taggable
tags := object.get(r.change.after, "tags", {})
missing := required_tags - {k | some k, _ in tags}
count(missing) > 0
msg := sprintf("%s '%s' is missing required tags: %v", [r.type, r.name, missing])
}
# Rule 2 — PostGIS instance-class ceiling.
deny contains msg if {
some r in resources
r.type == "aws_db_instance"
class := r.change.after.instance_class
not class in approved_db_classes
msg := sprintf("PostGIS instance '%s' uses unapproved class %q", [r.name, class])
}
# Rule 3 — no public raster buckets. Block the public-access-block resource
# whenever any public flag is disabled; matches the modern split resource shape.
deny contains msg if {
some r in resources
r.type == "aws_s3_bucket_public_access_block"
after := r.change.after
not all([after.block_public_acls, after.block_public_policy,
after.ignore_public_acls, after.restrict_public_buckets])
msg := sprintf("raster bucket '%s' does not fully block public access", [r.name])
}
The CI job that feeds this policy pins every tool version so a run today reproduces months from now. Note that the policy job holds no mutating credentials — it reads the plan artifact only.
#!/usr/bin/env bash
# ci/policy-gate.sh — fails closed on any error or deny.
set -euo pipefail
# Pinned toolchain: terraform 1.10.5, conftest 0.56.0, opa bundled in conftest.
terraform plan -out=plan.bin
terraform show -json plan.bin > plan.json
# Per-environment ceiling supplied as data; prod uses a stricter list than dev.
conftest test plan.json \
--policy policy/ \
--data "env/${DEPLOY_ENV}/limits.json" \
--all-namespaces
# conftest returns non-zero on any deny — the pipeline stops here before apply.
For the authoritative rule syntax and CLI flags, consult the official Open Policy Agent documentation and the Conftest documentation.
Guardrails Embedded in Configuration
The first embedded guardrail is that policy runs against plan JSON, so every rule reasons about the change set. The resource_changes array in a Terraform plan carries a change.actions list and a change.after object; filtering out deletes and reading after is what lets a rule judge the resource as it will exist. A rule that instead reads input.planned_values sees the whole resulting graph but loses the create-versus-update distinction, which matters when a rule should only fire on newly created public buckets. Choose the representation deliberately and document which one each rule uses.
The second guardrail is deny-by-default coverage. Rather than enumerating forbidden instance classes, the sizing rule enumerates the approved set and denies anything outside it, so a newly released, expensive instance family is blocked until someone explicitly adds it. The same inversion applies to buckets: the public-access rule fails unless all four block flags are true, so a bucket that simply omits the public_access_block resource entirely is caught by a companion rule that requires the resource to be present. Enumerating the safe set rather than the unsafe set is the difference between a policy that ages well and one that leaks with every new AWS feature.
The third guardrail is a first-class exemption path. Legitimate exceptions exist — a one-off analytics box that genuinely needs an unapproved instance class — and if the only way to ship them is to disable the rule, the rule will be disabled. Encode exemptions as data: a signed, expiring allow-list keyed by resource address and reviewed in the same pull request as the resource, so the exception is auditable and time-bounded rather than a permanent hole. An exemption without an expiry is a silent policy rollback.
The fourth guardrail is spatial-parameter assertion. Where a tile pipeline depends on a fixed CRS, the policy can assert that a reprojection parameter or an environment variable carries the expected SRID, catching a copy-paste that would otherwise ship tiles in the wrong projection and corrupt every downstream map. These correctness rules are cheap to write and catch a class of error that generic cloud-misconfiguration scanners never look for.
Troubleshooting and Failure Modes
Policy evaluates the plan but the resource still drifts. A rule that passes at plan time guarantees nothing about the resource a week later, because plan JSON describes intent, not the running cloud. If a raster bucket is made public by a console edit after apply, the plan gate never sees it — that is a job for a scheduled Drift Detection and Remediation for Geospatial Estates sweep. Treat the plan gate and the drift sweep as two halves of one control; neither alone is complete.
Wildcard resource match misses a new geospatial type. A rule written to inspect aws_s3_bucket silently ignores tiles provisioned through the split resource model (aws_s3_bucket_public_access_block, aws_s3_bucket_server_side_encryption_configuration), and a rule keyed on aws_db_instance never sees a PostGIS cluster stood up as aws_rds_cluster for Aurora. The symptom is a green policy run on a resource everyone assumed was covered. Guard against it by testing the suite against a fixture plan for every resource type the platform deploys, and fail the test if a fixture yields zero rule evaluations.
Reading planned_values instead of resource_changes. A rule that iterates input.planned_values.root_module.resources cannot distinguish a create from an update and cannot see deletes at all, so an update that removes required tags can pass while a create with the same shape is denied — an inconsistency that looks like a flaky policy. Standardize on resource_changes for any rule whose verdict depends on the action being taken.
Empty or partial plan JSON passes vacuously. If terraform show -json runs against a stale or truncated plan file, the resource_changes array is empty and every deny rule finds nothing to deny, so the gate reports success on a plan it never actually inspected. Fail closed: assert that the plan JSON is non-empty and that the expected resource count is present before trusting a clean result.
Exemptions with no expiry become permanent holes. An allow-list entry added to unblock one urgent deploy, then never removed, silently exempts that resource forever — and the next audit finds an unencrypted database nobody remembers waiving. Give every exemption a mandatory expiry timestamp and fail the policy run when an entry is past its date, so waivers decay instead of accumulating.
Related
- Pipeline Orchestration for Spatial Deployments — where the plan-JSON policy stage fits in the deploy pipeline.
- Drift Detection and Remediation for Geospatial Estates — the post-apply half of the control the plan gate cannot cover.
- Enforcing Tagging and Sizing Policies with OPA for PostGIS — a concrete OPA guardrail for PostGIS tags and instance class, start to finish.
- Cost Estimation Frameworks — pairing sizing policy with pre-apply cost gates.
- IAM Role Mapping for GIS — the identity model that tagging and access policies must align with.
- CI/CD, Drift Detection & Policy Governance — the governance framework this policy layer sits within.