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.

Cross-account access without a standing trust

Spatial estates end up multi-account for good reasons — a partner supplies imagery, an analytics team works in its own account, production is isolated from everything else — and each of those relationships needs a route across the boundary. The default route, adding the other account to a trust policy, is also the one that ages worst, because a standing trust persists long after the project that justified it and grants whatever the assumed role happens to hold at any future moment.

Two narrower mechanisms are usually available and both should be preferred. A resource policy on the specific resource — the bucket, the key, the queue — grants the external principal access to exactly that resource, and it is visible on the resource itself rather than in a role definition nobody is looking at. Where the external party only needs to read published data, this is almost always sufficient and it never grows into anything else.

Where an assumed role genuinely is required, an external identifier and a condition on the source narrow it considerably. The external identifier defends against the confused-deputy problem, in which a third party who manages infrastructure for several customers can be induced to act against the wrong one. A condition restricting the source to a specific role, network path or organisation prevents the trust from being usable by anything other than the intended caller. Neither is difficult, and both are routinely omitted because the unconditioned version works on the first attempt.

The habit that matters most is recording the expiry alongside the grant. Cross-account relationships have a natural end — a project completes, a partnership lapses, a team moves in-house — and almost none of them are revoked when that happens, because revocation is nobody’s task and the grant is nobody’s problem. Writing the intended end date into the resource’s tags, and reviewing those dates on a schedule, converts an indefinite standing trust into one that has to be renewed deliberately.

Mapping workloads to roles without accumulating grants

Identity in a spatial estate degrades in a characteristic way: roles accumulate. A grant is added to unblock a job, the job is retired, the grant remains; a role is reused for a second workload because creating one felt heavier than extending one; a policy grows a wildcard because enumerating the resources was tedious on a Friday. None of these are decisions anyone would defend individually, and together they produce a role that can do considerably more than anyone believes.

The structural remedy is to make the mapping from workload to role explicit and one-to-one, and to derive both from the same module that provisions the workload. A renderer gets a renderer role created beside it; an ingestion job gets its own; an analyst gets a person-scoped role rather than a shared team credential. The cost of that discipline is more roles, which is cheap, and the benefit is that every role has exactly one consumer — so its grants can be reasoned about, and revoking it affects exactly one thing.

One workload, one role, grants traceable to a need Each workload has exactly one role and each grant on that role traces to something the workload demonstrably does. The renderer role reads the publishing schema and decrypts the tile key, and holds nothing else, because rendering is a pure read. The ingestion role writes the staging prefix and reads the source prefix, and cannot touch published output. The analyst role reads published data broadly and writes only into scratch. When every grant has a named reason, an unused grant is visible as one whose reason no longer exists — which is the only practical way to reverse the accumulation that otherwise turns every long-lived role into an approximate administrator. Workload Its role holds, and nothing more Tile renderer read the publishing schema · decrypt the tile key Ingestion job read source prefix · write staging prefix Analyst read published data · write only into scratch A grant with no current reason is the one to remove — and it is only visible when reasons were recorded.

Three practices keep the mapping from decaying. Record the reason with the grant. A comment naming what needs a permission turns a later review from archaeology into reading, and it is the only way an unused grant becomes visible as one whose reason no longer exists. Prefer a new role to an extended one. Extending an existing role is almost always the faster path in the moment and almost always the one that produces a role nobody can safely revoke a year later. Review on workload retirement, not on a calendar. The moment a workload is decommissioned is the moment its role should go, and a decommissioning checklist that includes it costs nothing while a quarterly access review that has to reconstruct the same information costs a day.

Where broad access is genuinely required — analysts are the usual case — the answer is a ceiling rather than a narrower grant, because narrowing the grant makes the person’s job harder without making the estate safer. A permission boundary, developed in Scoping Permission Boundaries for GIS Analyst Roles, lets the grant stay broad while making the consequences of a mistake bounded, which is the right trade for a human role and the wrong one for a machine role that should simply be scoped precisely.

Machine identity and human identity need different designs A machine role should be scoped precisely, because its behaviour is known in advance and any permission beyond that is surplus. A human role cannot be, because an analyst's work is exploratory and a narrowly scoped role produces a ticket queue rather than security. The right instrument differs accordingly: precision for machines, a ceiling for humans. Applying either design to the other produces the two familiar failures — an over-privileged service, or an analyst who works around the platform. Machine role behaviour known — scope it precisely Human role work is exploratory — set a ceiling Precision applied to a human a ticket queue, not security A ceiling applied to a machine an over-privileged service

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.