Debugging Access Denied on Cross-Account Raster Buckets

A GeoServer instance or Lambda function in account A returns AccessDenied when it reads Cloud Optimized GeoTIFFs from an S3 bucket owned by account B, even though the role, the bucket policy, and the deployment all look correct in isolation. Cross-account raster access fails at the intersection of three policies — the identity policy on the reading role, the resource policy on the bucket, and the KMS key policy if the objects are encrypted — and a denial in any one of them presents identically. This guide is the cross-account debugging companion to IAM Role Mapping for GIS within the Network Security & Access Control framework, and it shows how to read CloudTrail to find which of the three layers actually denied the request before you touch any policy.

Symptom Identification and Triage

A cross-account AccessDenied is not one failure mode but several that share an error string. The decisive signal is the eventName and errorCode in the CloudTrail record from the bucket-owner account (account B), because a kms:Decrypt denial and an s3:GetObject denial demand entirely different fixes:

  • AccessDenied on s3:GetObject, no KMS event: the object is unencrypted or SSE-S3, and the denial is in the S3 evaluation itself — either the identity policy in account A omits the action or resource, or the bucket policy in account B does not grant the account-A principal. This is the most common case.
  • AccessDenied on kms:Decrypt immediately after a permitted GetObject: S3 authorized the object read, but the object is SSE-KMS and the reading role is not a grantee on the KMS key policy in account B. The tell is that the CloudTrail event source is kms.amazonaws.com, not s3.amazonaws.com — reading only the S3 events sends you fixing the wrong policy.
  • AccessDenied with no CloudTrail event at all in account B: the request never reached the bucket owner’s account. The denial is upstream in account A’s identity policy or an SCP boundary, or the request is going to the wrong bucket ARN entirely.
  • 403 that resolves to an object owned by a different account: Object Ownership is set to ObjectWriter and a producer in a third account wrote objects the bucket owner cannot re-grant, so the bucket policy is powerless over those keys.

Triage means locating the deny point on the three-way intersection before editing anything, because widening the wrong policy leaves the real gap open and adds a new one.

Triage tree for a cross-account raster AccessDenied A cross-account 403 AccessDenied leads to reading the CloudTrail record in the bucket-owner account, account B. The decision is which event appears. If no CloudTrail event appears in account B, the request never left account A, so repair the account-A identity policy or lift an SCP boundary. If an s3:GetObject AccessDenied appears, repair the bucket policy in account B or fix Object Ownership and ACLs. If a kms:Decrypt AccessDenied appears after a permitted GetObject, add the reading role as a grantee on the KMS key policy in account B. Each branch names one policy so the fix targets the true deny point. 403 AccessDenied reading COG in account B Read CloudTrail in account B which event / errorCode? No event in B never left account A s3:GetObject deny source s3.amazonaws.com kms:Decrypt deny source kms.amazonaws.com Fix identity policy in A or lift the SCP Fix bucket policy in B + Object Ownership Add role as grantee on KMS key policy in B Locate the deny point on the three-way intersection before editing any policy.

Prerequisites and Environment Assumptions

This guide assumes a two-account topology: a consumer account A running GeoServer on EC2/ECS or a Lambda reprojection function, and a producer account B owning the raster bucket. To debug and fix it you need:

  • Terraform >= 1.6 with hashicorp/aws pinned at ~> 5.60, applied separately against each account (or via a provider alias per account). Pin the provider so IAM defaults do not shift mid-incident.
  • Cross-account read access to CloudTrail in account B, or a data-events trail that captures S3 object-level and KMS events — without object-level logging the decisive GetObject/Decrypt events never appear.
  • IAM permissions for the operator: iam:GetRolePolicy and iam:SimulatePrincipalPolicy in account A, s3:GetBucketPolicy/s3:PutBucketPolicy and s3:GetBucketOwnershipControls in account B, and kms:GetKeyPolicy/kms:PutKeyPolicy on the encryption key in account B.
  • The exact reading principal ARN. The role that GeoServer or the Lambda assumes, resolved to its full arn:aws:iam::<A>:role/... form — the deepest treatment of scoping that role lives in Pulumi IAM Policies for S3 Raster Access.

Step-by-Step Remediation

Cross-account access requires both accounts to say yes: account A must grant the action to its role, and account B must grant the account-A principal on the resource. Fix them in the order the request is evaluated.

  1. Confirm the identity policy in account A grants the action. The reading role needs s3:GetObject (and s3:ListBucket if the code lists) on the account-B bucket ARN, plus kms:Decrypt on the key ARN if objects are SSE-KMS. A missing kms:Decrypt here is the single most common cross-account raster bug, because the identity policy that grants s3:GetObject silently omits the KMS action.

    # Account A — identity policy attached to the GeoServer / Lambda reading role.
    data "aws_iam_policy_document" "read_cross_account_rasters" {
      statement {
        sid       = "ReadCrossAccountCOGs"
        effect    = "Allow"
        actions   = ["s3:GetObject"]
        resources = ["arn:aws:s3:::acct-b-raster-store/cogs/*"]
      }
      statement {
        sid       = "DecryptCrossAccountKey"
        effect    = "Allow"
        actions   = ["kms:Decrypt"]                    # omitting this is the #1 SSE-KMS bug
        resources = ["arn:aws:kms:us-east-1:222222222222:key/abcd-key-id"]
      }
    }
  2. Grant the account-A principal in the bucket policy in account B. The resource policy must name the reading role (or account A’s root) as Principal and allow s3:GetObject on the object prefix. Scope it to the COG prefix so the grant does not expose the whole bucket.

    # Account B — resource policy on the raster bucket.
    data "aws_iam_policy_document" "raster_bucket" {
      statement {
        sid       = "AllowAcctAGeoServerRead"
        effect    = "Allow"
        principals {
          type        = "AWS"
          identifiers = ["arn:aws:iam::111111111111:role/GeoServerReadRole"]
        }
        actions   = ["s3:GetObject"]
        resources = ["arn:aws:s3:::acct-b-raster-store/cogs/*"]
      }
    }
  3. Add the reading role as a grantee on the KMS key policy in account B. For SSE-KMS objects, the bucket policy alone is not enough — the key policy in account B must allow kms:Decrypt for the account-A role. This is the layer CloudTrail’s kms.amazonaws.com event points at, and it is invisible if you look only at S3.

    # Account B — statement added to the KMS key policy.
    data "aws_iam_policy_document" "raster_key" {
      statement {
        sid       = "AllowAcctADecrypt"
        effect    = "Allow"
        principals {
          type        = "AWS"
          identifiers = ["arn:aws:iam::111111111111:role/GeoServerReadRole"]
        }
        actions   = ["kms:Decrypt"]
        resources = ["*"]   # key policy resource is the key itself
      }
    }
  4. Neutralize Object Ownership and ACL surprises. If objects were written by a third account with ObjectWriter ownership, the bucket owner cannot re-grant them through the bucket policy. Set Object Ownership to BucketOwnerEnforced so ACLs are disabled and the bucket policy becomes the single source of truth — then re-copy any orphaned objects so the bucket owner holds them.

Verification

Confirm all three layers agree before closing the ticket:

  • Simulate the principal. From account A, run aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111111111111:role/GeoServerReadRole --action-names s3:GetObject kms:Decrypt --resource-arns arn:aws:s3:::acct-b-raster-store/cogs/dem.tif and require allowed for both actions. An implicitDeny on kms:Decrypt with allowed on s3:GetObject is the exact SSE-KMS gap.
  • Read a real COG. From the GeoServer/Lambda role, aws s3 cp s3://acct-b-raster-store/cogs/dem.tif - (or a ranged GET of the COG header) must return 200. A ranged read matters for COGs because GeoServer reads the header and overviews before the full raster.
  • Confirm the deny is gone in CloudTrail. The follow-up GetObject and Decrypt events in account B should show no errorCode, and the Decrypt event should name the account-A role as the principal.
  • Prove scope is tight. A read of an out-of-prefix key (e.g. source/*) should still return 403, confirming the grant is scoped to cogs/* and not the whole bucket.

Preventing Recurrence

  • Encode both sides together. Keep the identity policy, bucket policy, and key-policy statements for a cross-account grant in one reviewed change so a GetObject grant never ships without its matching kms:Decrypt. This is the same paired-provisioning discipline that keeps cloud and database grants in sync in IAM Role Mapping for GIS.
  • Policy-as-code assertion. Add a rule that fails any cross-account S3 read grant on a KMS-encrypted bucket that lacks a corresponding kms:Decrypt grant on the key — this blocks the highest-frequency regression at merge.
  • Enforce BucketOwnerEnforced. Assert Object Ownership is BucketOwnerEnforced on every raster bucket so ACLs can never fragment ownership and make the bucket policy powerless.
  • Scheduled access review. Run simulate-principal-policy on a schedule for each cross-account reader so a policy drift surfaces as a failed check rather than a production 403.

Frequently Asked Questions

My bucket policy grants the role but I still get AccessDenied — why?

The objects are almost certainly SSE-KMS and the KMS key policy in the bucket-owner account does not grant kms:Decrypt to your role. Check CloudTrail for a kms.amazonaws.com event source — an s3:GetObject that is permitted followed by a kms:Decrypt deny is the signature. Add your role as a grantee on the key policy.

Do I need the grant in both the identity policy and the bucket policy?

Yes. Cross-account access requires both accounts to agree: the identity policy in the consumer account must allow the action, and the resource policy in the owner account must allow the consumer principal. Either one alone is not sufficient for a cross-account request.

There is no CloudTrail event in the bucket owner's account at all. What does that mean?

The request never reached the owner account, so the denial is upstream — usually a missing action in the consumer role’s identity policy, an SCP boundary, or a request going to the wrong bucket ARN. Fix account A’s side first; the owner-account policies are not the problem yet.

Why does Object Ownership matter for cross-account rasters?

If objects were written by a third account under ObjectWriter ownership, the bucket owner does not own those keys and cannot grant access to them through the bucket policy. Setting BucketOwnerEnforced disables ACLs and makes the bucket policy authoritative, after which re-copying the objects restores owner control.