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:
AccessDeniedons3: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.AccessDeniedonkms:Decryptimmediately after a permittedGetObject: 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 iskms.amazonaws.com, nots3.amazonaws.com— reading only the S3 events sends you fixing the wrong policy.AccessDeniedwith 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.403that resolves to an object owned by a different account: Object Ownership is set toObjectWriterand 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.
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.6withhashicorp/awspinned 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/Decryptevents never appear. - IAM permissions for the operator:
iam:GetRolePolicyandiam:SimulatePrincipalPolicyin account A,s3:GetBucketPolicy/s3:PutBucketPolicyands3:GetBucketOwnershipControlsin account B, andkms:GetKeyPolicy/kms:PutKeyPolicyon 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.
-
Confirm the identity policy in account A grants the action. The reading role needs
s3:GetObject(ands3:ListBucketif the code lists) on the account-B bucket ARN, pluskms:Decrypton the key ARN if objects are SSE-KMS. A missingkms:Decrypthere is the single most common cross-account raster bug, because the identity policy that grantss3:GetObjectsilently 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"] } } -
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
Principaland allows3:GetObjecton 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/*"] } } -
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:Decryptfor the account-A role. This is the layer CloudTrail’skms.amazonaws.comevent 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 } } -
Neutralize Object Ownership and ACL surprises. If objects were written by a third account with
ObjectWriterownership, the bucket owner cannot re-grant them through the bucket policy. Set Object Ownership toBucketOwnerEnforcedso 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.tifand requireallowedfor both actions. AnimplicitDenyonkms:Decryptwithallowedons3:GetObjectis 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 return200. 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
GetObjectandDecryptevents in account B should show noerrorCode, and theDecryptevent 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 return403, confirming the grant is scoped tocogs/*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
GetObjectgrant never ships without its matchingkms: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:Decryptgrant on the key — this blocks the highest-frequency regression at merge. - Enforce
BucketOwnerEnforced. Assert Object Ownership isBucketOwnerEnforcedon every raster bucket so ACLs can never fragment ownership and make the bucket policy powerless. - Scheduled access review. Run
simulate-principal-policyon a schedule for each cross-account reader so a policy drift surfaces as a failed check rather than a production403.
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.
Related
- IAM Role Mapping for GIS — the parent guide on trust policies, session tags, and cross-account delegation.
- Pulumi IAM Policies for S3 Raster Access — scoping the reading role and recovering drifted resource policies.
- VPC Routing for Tile Servers — ruling out network-layer denials before IAM.
- Object Storage for Raster/Vector Workloads — the bucket and ownership model behind the raster store.