IAM Role Mapping for GIS Workloads

Spatial platforms fail quietly when identity is an afterthought: a tile renderer that can list every bucket in the account, a database role that drifts out of sync with its cloud identity, or a cross-account trust policy with no external ID. Mapping cloud IAM identities onto the concrete actors of a geospatial stack — tile renderers, raster ingestion workers, vector pipelines, and read-only analytics services — is the work that keeps least-privilege real as the platform grows. This resource area sits inside the broader Network Security & Access Control framework and is tightly coupled to VPC Routing for Tile Servers and Security Group Hardening: identity, network path, and port exposure must enforce the same boundary or the weakest of the three defines your actual posture. Codifying role mapping in Terraform or Pulumi turns it from a console-clicking chore into a reviewable, versioned contract that renders consistent identities across development, staging, and production.

Environment parity and configuration drift mitigation

The recurring failure in spatial IAM is divergence between environments. A gis-tile-renderer role granted broad s3:* in development “to unblock a demo” is rarely tightened before it reaches production, and a permission added by hand during an incident never makes it back into source control. Both produce drift that no diff will show you until an audit — or an attacker — finds it.

Treat the role definition as a single parameterized module instantiated once per environment. The structural shape (trust policy, attached policy documents, condition keys) stays identical; only the inputs change — bucket ARNs, allowed CIDR ranges, session duration, and the environment-scoped object prefix. This guarantees that a tightening applied in development is structurally present in production, because both render from the same code path.

  • Lock the trust boundary, not just the permissions. Environment parity means the assume_role_policy principal, ExternalId, and source-condition keys are identical in shape across environments — only the values differ. A role that is assumable by ecs-tasks.amazonaws.com in staging must not silently become assumable by a broad * principal in production.
  • Scope object access by environment prefix. Raster and tile reads should be bounded to s3://bucket/rasters/${environment}/* so a staging role physically cannot read production imagery, mirroring the prefix discipline established in Object Storage for Raster/Vector Workloads.
  • Pin provider versions. A minor AWS provider bump can change default IAM behaviours (for example, inline vs managed policy handling). Pinning required_providers keeps plan output deterministic across the promotion cycle.
  • Detect drift on a schedule, not just on apply. A nightly terraform plan -detailed-exitcode against production state surfaces console-applied permission changes as a non-zero exit before they become a standing risk.

State itself is part of parity. IAM policy documents contain account IDs, role ARNs, and condition values that should never sit in plaintext, so role modules must run against a locked remote backend — see State Backend Selection and the locking mechanics in Managing Terraform State Locks for Spatial Data for why concurrent role mutations without a lock can corrupt the recorded trust relationship.

CI/CD validation and operational guardrails

Identity changes are among the highest-blast-radius edits in a spatial platform, so they belong behind the strictest pull-request gates. The objective is to make an over-permissive policy un-mergeable, not merely discouraged.

A practical gate sequence in the pull-request pipeline:

  1. Static policy analysis. Run checkov and cfn-nag over the rendered plan, and feed the candidate policy to AWS IAM Access Analyzer’s policy validation API. These catch wildcard Action/Resource pairs, missing condition keys, and trust policies that admit overly broad principals before any apply.
  2. Plan diffing on the IAM graph. Surface terraform plan (or pulumi preview) output as a PR comment, with reviewers required specifically when an aws_iam_role, aws_iam_role_policy, or trust policy changes. A privilege expansion should never merge on a silent diff.
  3. Policy-as-code assertions. Encode organisational rules — “no s3:* on raster buckets”, “every cross-account trust must carry an ExternalId”, “session duration ≤ 1 hour for renderer roles” — as OPA/Conftest or Sentinel policies that fail the build deterministically.
  4. Rollback triggers. Because IAM mutations can lock out the very pipeline that applies them, keep the previous known-good policy document tagged in state so a revert is a single apply of the prior revision rather than an emergency console edit.

These guardrails are the identity-layer equivalent of the pre-apply integrity checks used elsewhere on the platform: the same PR workflow that validates a spatial index or a parameter group should refuse a role that widens the blast radius.

Resource architecture and service integration

IAM role mapping is the connective tissue between the platform’s compute, storage, and data tiers, and it never operates in isolation. Each actor in a spatial stack needs a role scoped to exactly its job:

  • Tile renderers (ECS tasks, Lambda, or EC2 behind a WMS/WMTS endpoint) need read-only access to the raster/COG prefix and the tile cache, plus permission to emit metrics. They should never hold write access to the source archive.
  • Ingestion workers that mosaic, reproject, or pyramid imagery need scoped write access to a staging prefix and read access to the landing zone — and nothing in the served tile cache.
  • Spatial databases. Where a PostGIS Cluster Provisioning deployment uses IAM database authentication, the cloud identity must map to a PostgreSQL role with matching grants: ingestion identities map to a writer role, analytics identities to a read-only role. The cloud-side and database-side grants must be provisioned together or they will drift.
  • Analytics and BI services that query vector tiles or run spatial joins need read-only retrieval, ideally through a VPC endpoint rather than the public internet.

Identity also has to agree with the network path. When a renderer role is scoped to a specific gateway VPC endpoint (for example com.amazonaws.us-east-1.s3), raster retrieval never touches the public internet — but only if the route table and endpoint policy described in VPC Routing for Tile Servers actually steer that traffic to the endpoint. Likewise, browser-facing spatial APIs depend on CORS & CSP Configuration to govern client access, while the backing role governs what the API can reach server-side. The deepest treatment of programmatic, prefix-scoped storage permissions lives in Pulumi IAM Policies for S3 Raster Access, which covers the incident-response and state-recovery angle of these same roles.

IAM role mapping from spatial actors to prefix-scoped S3 access A map client sends a request to the tile renderer, which serves WMS and WMTS. Three actors each assume their own scoped role through STS: the tile renderer assumes gis-tile-renderer (read-only, 1 hour session) and reads s3:GetObject limited by the condition s3:prefix = rasters/${environment}/* through a gateway VPC endpoint; the ingestion worker assumes gis-ingestion and is allowed s3:PutObject only into staging/${environment}/*; the analytics service assumes gis-analytics for read-only s3:GetObject on the vector prefix. All trust policies use the ecs-tasks principal and are gated by aws:SourceIp within the egress CIDR and aws:RequestedRegion equal to the home region, with an ExternalId required on any cross-account assume. Map client request Actors Scoped IAM roles (STS) S3 raster / vector bucket Tile renderer (ECS) WMS · WMTS endpoint Ingestion worker mosaic · reproject Analytics / BI vector queries gis-tile-renderer read-only · 1h session gis-ingestion write staging only gis-analytics read-only AssumeRole AssumeRole AssumeRole Gateway VPC endpoint (s3) + endpoint policy rasters/${env}/* COG · source imagery staging/${env}/* ingest scratch space vector/${env}/* tiles · features s3:GetObject (read-only) s3:prefix = rasters/${env}/* s3:PutObject (write) scoped to staging/${env}/* s3:GetObject (read-only) via VPC endpoint only Trust policy (all roles): Principal = ecs-tasks.amazonaws.com · Condition: aws:SourceIp ∈ egress CIDR · aws:RequestedRegion = home region Cross-account assume additionally requires an ExternalId condition. An explicit deny in a permission boundary / SCP overrides any role grant.

Runnable configuration

The module below renders a tile-renderer role with an environment-scoped trust policy and prefix-isolated raster access. Every input is parameterized so the same code produces a tightened production identity and a sandboxed development one. Provider versions are pinned so plan output stays deterministic across the promotion cycle.

# terraform/modules/gis_iam_role/main.tf
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }
}

variable "environment" {
  type        = string
  description = "Deployment environment (dev | staging | prod); scopes the role name and object prefix."
}

variable "allowed_cidrs" {
  type        = list(string)
  description = "Source CIDRs permitted to assume the role (e.g. the NAT/VPC egress range for renderer tasks)."
}

variable "raster_bucket_arn" {
  type        = string
  description = "ARN of the bucket holding source rasters and COG imagery."
}

data "aws_region" "current" {}

# Identity for the tile-rendering tier. Assumable only by ECS tasks,
# only from the platform egress range, and only in the home region.
resource "aws_iam_role" "gis_tile_renderer" {
  name                 = "gis-tile-renderer-${var.environment}"
  max_session_duration = 3600 # 1 hour: short-lived credentials for a stateless renderer

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
      Condition = {
        StringEquals = { "aws:RequestedRegion" = data.aws_region.current.name }
        IpAddress    = { "aws:SourceIp" = var.allowed_cidrs }
      }
    }]
  })

  tags = { Component = "tile-renderer", Environment = var.environment }
}

# Read-only, prefix-isolated raster access. ListBucket is constrained
# to the environment prefix so a dev role cannot enumerate prod imagery.
resource "aws_iam_role_policy" "raster_read" {
  name = "raster-prefix-access-${var.environment}"
  role = aws_iam_role.gis_tile_renderer.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ReadScopedRasters"
        Effect   = "Allow"
        Action   = ["s3:GetObject"]
        Resource = ["${var.raster_bucket_arn}/rasters/${var.environment}/*"]
      },
      {
        Sid      = "ListScopedPrefixOnly"
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = [var.raster_bucket_arn]
        Condition = {
          StringLike = { "s3:prefix" = ["rasters/${var.environment}/*"] }
        }
      }
    ]
  })
}

The split between GetObject (scoped to the object path) and ListBucket (scoped via the s3:prefix condition on the bucket ARN) is deliberate: granting ListBucket on the object path silently does nothing, a common mistake that leaves renderers unable to enumerate tiles while operators assume the policy is correct.

Guardrails embedded in configuration

The strongest controls are the ones baked into the resource definition rather than bolted on by a runbook:

  • State locking is non-negotiable. Run the role module against a locked remote backend so two pipelines cannot mutate the same trust policy concurrently and leave state describing a relationship that does not exist in the account.
  • No long-lived secrets in roles. Roles deliver temporary STS credentials by design; max_session_duration is capped at one hour for stateless renderers so a leaked credential expires fast. Where static secrets are unavoidable (a database password for non-IAM auth), reference them from Secrets Manager and mark Terraform outputs sensitive / encrypt Pulumi config — never inline them in policy JSON.
  • Network isolation reinforces identity. Scope storage permissions to a VPC endpoint and pair them with an endpoint policy so that even a valid credential used from outside the expected network path is denied. Identity and route must agree.
  • Permission boundaries and SCPs cap the ceiling. Codify Service Control Policies and IAM permission boundaries alongside the role so that an explicit deny survives any manual console edit — the platform’s hard upper bound on what a role can ever do.
  • External IDs on every cross-account trust. A sts:AssumeRole from a partner or SaaS account must require an ExternalId condition and, where appropriate, MFA, closing the confused-deputy path between analytics and production accounts.

Troubleshooting and failure modes

  • S3 prefix scope mismatch. Renderers return empty tiles or AccessDenied because ListBucket was granted on the object path instead of the bucket ARN with an s3:prefix condition, or because the environment prefix in the policy does not match the prefix in the request. Confirm the s3:prefix condition matches the renderer’s actual key layout; a CloudTrail AccessDenied on ListBucket with an empty prefix is the signature.
  • VPC endpoint policy gap. Identity allows the read, but the gateway endpoint’s own policy does not, so requests fail with AccessDenied even though the role looks correct. The IAM policy and the endpoint policy must both permit the action — check the endpoint policy whenever a role audit shows the grant is present but reads still fail.
  • Trust policy region/IP skew. Tasks cannot assume the role after a region migration or a NAT/egress IP change because the aws:RequestedRegion or aws:SourceIp condition no longer matches. The symptom is AssumeRole failures in CloudTrail with no matching Allow; re-render the module with the current CIDRs.
  • Cross-account confused deputy. A trust policy missing its ExternalId lets a third party assume the role on a victim’s behalf. Audit every cross-account assume_role_policy for a sts:ExternalId condition; its absence is the finding.
  • IAM database auth role skew. Cloud identity exists but the matching PostgreSQL role was never granted (or was dropped), so connections authenticate at the cloud layer then fail at the database with a permission error. Provision the cloud role and the database grant in the same module so they cannot drift apart.

For provider-specific behaviour, consult the Terraform AWS Provider IAM Role documentation and the AWS IAM policy evaluation logic reference, in which an explicit deny in a boundary policy always overrides a role permission.