Scoping Permission Boundaries for GIS Analyst Roles

A GIS analyst needs a genuinely awkward set of permissions. They must read imagery archives that cost real money to transfer, query a spatial database that holds authoritative geometry, launch compute large enough to reproject a national coverage, and write results somewhere colleagues can find. Granting that as a policy attached directly to their role produces something close to administrator; granting less produces a ticket queue and an analyst who works around the platform instead of on it. A permission boundary resolves this by separating what a role may be granted from what it is granted, which lets you delegate the granting safely. This guide extends IAM Role Mapping for GIS within Network Security and Access Control.

How a boundary differs from a policy

A permission boundary is a ceiling, not a grant. Attaching one to a role grants nothing; it constrains the maximum any identity policy on that role can achieve. Effective permissions are the intersection of the identity policy and the boundary, so a role with an administrator policy and a narrow boundary has narrow permissions.

That indirection is what makes delegation safe. A platform team can allow analysts — or an analyst-facing self-service tool — to create and modify their own roles, provided every role they create carries the boundary. The boundary then guarantees that no matter what policy is attached, the resulting role cannot delete a production database, cannot write to the authoritative geometry bucket, and cannot escalate its own privileges by editing the boundary itself.

The escalation clause is the part most often missed. A boundary that permits iam:* allows an analyst to detach their own boundary, at which point it protects nothing. Every boundary must explicitly deny the IAM actions that would let a principal modify boundaries, policies or roles.

Effective permissions as the intersection of policy and boundary Two overlapping regions. The identity policy attached to the role describes what has been granted, and may be broad. The permission boundary describes the maximum the role may ever achieve, and is narrow. Effective permissions are the intersection of the two, so a role carrying an administrator policy and a narrow boundary has narrow permissions. This is what makes it safe to delegate role creation, because whatever policy is attached the boundary still holds. The essential condition is that the boundary itself denies the IAM actions that would let a principal detach or edit a boundary; a boundary permitting broad IAM access protects nothing. Identity policy may be broad Permission boundary the ceiling, narrow Effective the intersection A boundary that permits broad IAM access lets a principal detach it — and then it protects nothing

Prerequisites and environment assumptions

Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A clear inventory of what analysts legitimately need, which is harder than it sounds and is best assembled from access logs rather than from a meeting. Tagged resources, because the most useful boundary conditions are tag-based — a boundary that permits writes only to resources tagged as a scratch environment depends entirely on those tags being reliable, which is the argument in FinOps Tagging Strategies for Geospatial Resources.

Decide the analyst’s write territory before writing any policy. The clean answer for a spatial platform is: read broadly across published imagery and published geometry, write only into a scratch prefix and a scratch schema that carry no retention guarantee, and never touch the authoritative store. That single sentence generates most of the boundary.

Step-by-step implementation

  1. Enumerate the allowed services at the boundary. A boundary is a ceiling, so start by listing the services an analyst may ever use — object storage, the database, the compute service, the metrics service — and permit nothing outside that list. Everything not enumerated is denied by omission, which is the correct default here.

  2. Constrain writes by prefix and by tag. Reads can be broad; writes must be narrow. Permit s3:PutObject only under the scratch prefix, and permit compute actions only on resources carrying the analyst’s team tag. This is where a boundary earns its keep, because it survives whatever policy someone attaches later.

  3. Deny the escalation paths explicitly. iam:DeleteRolePermissionsBoundary, iam:PutRolePermissionsBoundary on their own role, iam:CreatePolicyVersion, and iam:AttachRolePolicy all belong in an explicit deny. An explicit deny cannot be overridden by any grant.

  4. Deny the expensive irreversible actions regardless of tags. Deleting a database cluster, deleting a bucket, disabling a key — none of these should be reachable by an analyst role even in a scratch environment, because scratch environments are where the tag is most likely to be wrong.

  5. Require the boundary on any role the analyst can create. If you are delegating role creation, the delegating policy must include a condition requiring the boundary be attached, or the delegation immediately defeats itself.

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.60" }
  }
}

variable "imagery_bucket_arn" { type = string }
variable "scratch_bucket_arn" { type = string }

data "aws_iam_policy_document" "analyst_boundary" {
  # Read broadly across published spatial data. Reads are the analyst's job.
  statement {
    sid       = "ReadPublishedSpatialData"
    effect    = "Allow"
    actions   = ["s3:GetObject", "s3:ListBucket", "s3:GetObjectVersion"]
    resources = [var.imagery_bucket_arn, "${var.imagery_bucket_arn}/*"]
  }

  # Write narrowly: only into scratch, which carries no retention guarantee.
  statement {
    sid       = "WriteScratchOnly"
    effect    = "Allow"
    actions   = ["s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload"]
    resources = ["${var.scratch_bucket_arn}/analysts/*"]
  }

  # Compute, but only on resources carrying the team tag. This is where the
  # tag contract stops being bookkeeping and becomes access control.
  statement {
    sid    = "TaggedComputeOnly"
    effect = "Allow"
    actions = [
      "batch:SubmitJob", "batch:DescribeJobs", "batch:TerminateJob",
      "logs:GetLogEvents", "logs:FilterLogEvents",
      "cloudwatch:GetMetricData", "cloudwatch:ListMetrics",
    ]
    resources = ["*"]
    condition {
      test     = "StringEquals"
      variable = "aws:ResourceTag/Team"
      values   = ["geospatial-analysis"]
    }
  }

  # Explicit deny beats every grant, in any policy, forever. Without this the
  # analyst can detach the boundary and the ceiling disappears.
  statement {
    sid    = "DenySelfEscalation"
    effect = "Deny"
    actions = [
      "iam:DeleteRolePermissionsBoundary",
      "iam:PutRolePermissionsBoundary",
      "iam:CreatePolicyVersion",
      "iam:SetDefaultPolicyVersion",
      "iam:AttachRolePolicy",
      "iam:PutRolePolicy",
      "organizations:*",
      "account:*",
    ]
    resources = ["*"]
  }

  # Irreversible destruction stays out of reach even inside scratch, because
  # scratch is exactly where a tag is most likely to be wrong.
  statement {
    sid    = "DenyIrreversibleDestruction"
    effect = "Deny"
    actions = [
      "rds:DeleteDBInstance", "rds:DeleteDBCluster", "rds:DeleteDBSnapshot",
      "s3:DeleteBucket", "s3:PutBucketPolicy",
      "kms:ScheduleKeyDeletion", "kms:DisableKey",
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "analyst_boundary" {
  name        = "gis-analyst-boundary"
  description = "Ceiling for analyst roles. Grants nothing on its own."
  policy      = data.aws_iam_policy_document.analyst_boundary.json
}

resource "aws_iam_role" "analyst" {
  name                 = "gis-analyst"
  permissions_boundary = aws_iam_policy.analyst_boundary.arn
  assume_role_policy   = data.aws_iam_policy_document.analyst_trust.json
}

# Delegation: analysts may create their own roles, but ONLY with the boundary
# attached. Without this condition the delegation defeats itself immediately.
data "aws_iam_policy_document" "analyst_role_delegation" {
  statement {
    effect    = "Allow"
    actions   = ["iam:CreateRole", "iam:PutRolePolicy"]
    resources = ["arn:aws:iam::*:role/analyst-scoped/*"]
    condition {
      test     = "StringEquals"
      variable = "iam:PermissionsBoundary"
      values   = [aws_iam_policy.analyst_boundary.arn]
    }
  }
}
Read broadly, write to scratch, never touch the authoritative store Three zones define the analyst's territory. The read zone covers published imagery and published geometry and is deliberately broad, because reading is the analyst's job. The write zone is confined to a scratch prefix in object storage and a scratch schema in the database, both of which carry no retention guarantee, so a mistake there costs the analyst's own work and nothing else. The authoritative store — the production database, the imagery archive of record, the encryption keys — is unreachable for writes and unreachable for destruction regardless of what identity policy is attached, because the boundary denies those actions explicitly. Read — broad published imagery published geometry metrics and logs this is the job Write — scratch only scratch/analysts/ prefix scratch database schema tagged compute jobs no retention guarantee Authoritative — never production database archive of record encryption keys denied regardless of policy The whole boundary follows from one sentence: read broadly, write to scratch, never touch the authoritative store. Everything else is the mechanical expression of it — plus the explicit deny that stops the boundary being removed.

Verification

Test the ceiling by trying to exceed it. Assume the analyst role and attempt, in order: a read from the imagery bucket (must succeed), a write to the scratch prefix (must succeed), a write to the imagery bucket (must fail), a database cluster deletion (must fail), and an attempt to attach an administrator policy to the role (must fail). The last one is the important test, because it proves the boundary cannot be removed by the identity it constrains.

Then verify the boundary is genuinely a ceiling rather than a grant: attach a deliberately over-broad identity policy to a test role carrying the boundary and confirm the effective permissions are still narrow. The policy simulator will show the boundary as the deny reason, which is the confirmation that the intersection is working as intended.

Test the ceiling by trying to exceed it Five attempts, run while assuming the analyst role. Reading from the imagery archive must succeed, because reading is the job. Writing into the scratch prefix must succeed, because that is the analyst's territory. Writing into the imagery archive must fail. Deleting a database cluster must fail. And attaching an administrator policy to the role must fail — that last attempt is the important one, because it proves the boundary cannot be removed by the identity it constrains, which is the property everything else rests on. Attempt, as the analyst role Required outcome read an object from the imagery archive succeeds write into the scratch prefix succeeds write into the imagery archive · delete a cluster fails attach an administrator policy to this role fails — the test that matters

Preventing recurrence

  • Require the boundary in policy-as-code. A rule that fails any plan creating an analyst-namespace role without the boundary makes the delegation self-enforcing, using the gate described in Policy as Code for Spatial Resources.
  • Review the deny list when a new service is adopted. A boundary enumerating allowed services is safe by default but becomes an obstacle as the platform grows; schedule the review rather than handling it as a series of urgent exceptions.
  • Keep scratch genuinely disposable. A lifecycle rule expiring the scratch prefix is what makes the “no retention guarantee” claim true, and what stops scratch quietly becoming a second authoritative store.
  • Build the inventory from access logs. The permissions people actually use differ substantially from the ones they say they need, in both directions.

Frequently Asked Questions

Does a permission boundary grant anything by itself?

No. It is purely a ceiling. A role with a boundary and no identity policy can do nothing at all. This surprises people the first time, and it is precisely the property that makes the boundary safe to attach broadly.

Why deny irreversible actions if the tag condition already limits scope?

Because tags are data and data is sometimes wrong. A scratch environment mistagged as production, or a production database mistagged as scratch, turns a tag-scoped grant into a production deletion. Irreversible actions deserve a deny that does not depend on a tag being correct.

Can analysts create their own roles safely?

Yes, with two conditions: the delegating policy must require the boundary on any role created, and it must confine creation to a dedicated path or namespace. Without the boundary condition the delegation grants role creation, which is equivalent to granting whatever the analyst is willing to write into a policy.

How does this relate to the roles a pipeline uses?

Differently, and they should not share a boundary. A pipeline role is machine-driven, narrowly scoped by design and reviewed as code; an analyst role is human-driven and needs latitude within a ceiling. The pipeline identity patterns are covered in GitHub Actions OIDC for Terraform Spatial Deploys.