<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Service Control Policies | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/service-control-policies/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/service-control-policies/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Tue, 20 Oct 2026 06:00:04 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Service Control Policies | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/service-control-policies/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Building an Audit Evidence Repository on AWS: Immutable Retention Without Regret</title>
		<link>https://john-nessime.com/blog/cloud-computing/audit-evidence-repository-aws/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 06:00:04 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[Append-Only Storage]]></category>
		<category><![CDATA[Audit Evidence]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Backup]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Data Governance]]></category>
		<category><![CDATA[Document Retention]]></category>
		<category><![CDATA[Immutable Storage]]></category>
		<category><![CDATA[Legal Hold]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[S3 Object Lock]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<category><![CDATA[WORM Storage]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=910</guid>

					<description><![CDATA[<p>Enabling S3 Object Lock on a bucket does not protect the objects already in it. This is how to build an audit evidence repository on AWS that survives the question auditors actually ask, covering retention modes you cannot undo, delete markers that hide protected versions, chain of custody, and the storage bill compliance mode makes permanent.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/audit-evidence-repository-aws/">Building an Audit Evidence Repository on AWS: Immutable Retention Without Regret</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The question that ends a smooth audit call is never &#8220;do you have the logs&#8221;. It&#8217;s &#8220;show me why nobody could have edited them.&#8221;</p>



<p class="wp-block-paragraph">That is a different question, and most evidence buckets fail it quietly. Versioning is on, the bucket is encrypted, access logging is enabled, and there is still an admin role in the account that can call <code>DeleteObjectVersion</code> on every object in it. Nothing is wrong until someone asks you to prove a negative.</p>



<p class="wp-block-paragraph">An audit evidence repository on AWS is not a bucket with a good name. It is a write path, a retention decision you cannot take back, and a set of guardrails that survive the person who built them leaving the company. This post covers how to build one with S3 Object Lock and AWS Backup Vault Lock, and more usefully, the five ways I have seen the design go wrong in ways nobody notices until the evidence is needed or the bill arrives.</p>



<h2 class="wp-block-heading">What &#8220;immutable&#8221; actually buys you on AWS</h2>



<p class="wp-block-paragraph">Three separate mechanisms get called immutability, and they protect different things.</p>



<ul class="wp-block-list">
<li><strong>S3 Object Lock</strong> stops an object version from being overwritten or deleted for a defined period. It works only on versioned buckets, and once you enable it on a bucket you cannot disable it or suspend versioning again.</li>



<li><strong>AWS Backup Vault Lock</strong> does the same job for recovery points in a backup vault, with its own retention floor and ceiling enforced at the vault level.</li>



<li><strong>Integrity validation</strong>, such as CloudTrail&#8217;s digest files, proves a file has not changed since delivery. That is a separate property from being undeletable, and auditors increasingly ask for both.</li>
</ul>



<p class="wp-block-paragraph">Object Lock is the core of an audit evidence repository on AWS because evidence usually arrives as files: exported reports, signed approvals, config snapshots, log archives. Vault Lock matters when the evidence is a backup of a database or an EBS volume. Integrity validation is what turns &#8220;we kept it&#8221; into &#8220;we kept it and here is the hash chain&#8221;.</p>



<h2 class="wp-block-heading">Failure one: picking the retention mode you cannot walk back</h2>



<p class="wp-block-paragraph">Object Lock has two retention modes and the difference is not cosmetic. In governance mode, a caller who holds the <code>s3:BypassGovernanceRetention</code> permission can shorten or remove retention and delete the version. In compliance mode, <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">nobody can</a>, including the account root user, and the retention period cannot be shortened or downgraded to governance.</p>



<p class="wp-block-paragraph">Read that second sentence again with a typo in mind. Set seven years where you meant seven days, in compliance mode, and the only exit AWS documents is closing the account. That is not a bug. It is the entire point of the mode. But it means the mode is a business decision wearing a technical costume.</p>



<p class="wp-block-paragraph">How I decide: compliance mode only where an external rule names a retention period and an external party will inspect it. Everything else starts in governance mode, with the bypass permission granted to nobody by default and denied outright in a service control policy. Governance mode with no principal holding the bypass right is functionally immutable for day-to-day purposes, and it leaves you a documented, auditable escape hatch that requires an explicit policy change to use.</p>



<p class="wp-block-paragraph">The bypass is not silent, either. Overriding governance retention requires the <code>x-amz-bypass-governance-retention:true</code> header on the request, which means the action shows up in CloudTrail as a distinctly shaped call. That is a good alarm to build.</p>



<pre class="wp-block-code"><code># Create the bucket with Object Lock on. Versioning is enabled automatically
# and cannot be suspended afterwards.
aws s3api create-bucket 
  --bucket my-evidence-archive 
  --object-lock-enabled-for-bucket 
  --region eu-west-1 
  --create-bucket-configuration LocationConstraint=eu-west-1

# Default retention for NEW objects written to the bucket.
aws s3api put-object-lock-configuration 
  --bucket my-evidence-archive 
  --object-lock-configuration 
  '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"GOVERNANCE","Days":365}}}'</code></pre>



<p class="wp-block-paragraph">Start there. Run it for a quarter. Watch what actually lands in the bucket and how big it gets. Then decide whether compliance mode is worth its permanence.</p>



<h2 class="wp-block-heading">Failure two: retention that was never applied to the objects that matter</h2>



<p class="wp-block-paragraph">This is the invisible one, and it is the reason to read the rest of this post.</p>



<p class="wp-block-paragraph">Object Lock is enabled at the bucket level, but retention lives on the individual object version. A default retention rule applies to objects written after the rule exists. It does not reach backwards. Enable Object Lock on an existing bucket holding three years of evidence and you get a bucket that reports Object Lock as enabled while every object already in it carries no retention at all.</p>



<p class="wp-block-paragraph">The bucket console looks correct. The compliance checklist gets ticked. The old evidence is still deletable by anyone with delete permissions.</p>



<p class="wp-block-paragraph">Two things to do about it. First, backfill retention on existing versions, either object by object with <code>put-object-retention</code> or, for anything above a few thousand keys, with S3 Batch Operations, which exists precisely for applying retention at scale. Second, verify per object rather than per bucket.</p>



<pre class="wp-block-code"><code># Check what protection a specific version actually carries.
# Look for ObjectLockMode, ObjectLockRetainUntilDate, ObjectLockLegalHoldStatus.
aws s3api head-object 
  --bucket my-evidence-archive 
  --key 2024/q3/access-review-export.csv 
  --version-id "SOME_VERSION_ID"</code></pre>



<p class="wp-block-paragraph">The stronger fix is to stop trusting the writer to set retention correctly and enforce a floor in the bucket policy. S3 exposes the <code>s3:object-lock-remaining-retention-days</code> condition key for exactly this, so you can reject writes that ask for too short a lock.</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RejectShortRetention",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::my-evidence-archive/*",
      "Condition": {
        "NumericLessThan": {
          "s3:object-lock-remaining-retention-days": "365"
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">The write path now fails loudly when someone gets it wrong, which is what you want. A rejected upload gets noticed. A silently unprotected object does not.</p>



<h2 class="wp-block-heading">Failure three: the delete that looks like a delete</h2>



<p class="wp-block-paragraph">Object Lock protects object versions. It does not stop new versions being created, and it does not stop delete markers being placed on top of a protected object.</p>



<p class="wp-block-paragraph">So a user with ordinary delete permissions can issue a delete against a locked object, get a success response, and make it vanish from every normal listing. The protected version is still there. Nothing was destroyed. But an auditor browsing the bucket sees nothing, and the person who ran the command believes the file is gone.</p>



<p class="wp-block-paragraph">Three practical consequences:</p>



<ul class="wp-block-list">
<li>Deny <code>s3:DeleteObject</code> on the evidence prefix for everyone except a break-glass role. Immutability is not a substitute for permissions, it is a backstop under them.</li>



<li>Any retrieval tooling you hand to an auditor must list object <em>versions</em>, not objects. <code>list-object-versions</code>, not <code>list-objects-v2</code>.</li>



<li>Lifecycle rules interact with this in a way that surprises people. An expiration rule on a versioned bucket adds a delete marker rather than removing data, and a rule that tries to permanently expire a noncurrent version will not remove a version still under retention. Your cleanup policy and your retention policy are two different clocks, and the lock always wins.</li>
</ul>



<h2 class="wp-block-heading">Failure four: evidence that arrives already unprovable</h2>



<p class="wp-block-paragraph">Immutability starts when the object lands. Everything before that is chain of custody, and it is where most repositories are weakest.</p>



<p class="wp-block-paragraph">If the evidence is CloudTrail, use the integrity validation CloudTrail already ships. With log file validation enabled, CloudTrail writes an hourly digest file containing hashes of the log files delivered in that hour, signs each digest with a per-region private key, and puts the digests in a separate folder in the same bucket. You can then verify the chain later.</p>



<pre class="wp-block-code"><code># Turn on digest files for an existing trail.
aws cloudtrail update-trail 
  --name org-audit-trail 
  --enable-log-file-validation

# Verify a time range later. Files must still be in the location
# CloudTrail delivered them to, or validation cannot run.
aws cloudtrail validate-logs 
  --trail-arn arn:aws:cloudtrail:eu-west-1:111122223333:trail/org-audit-trail 
  --start-time 2020-01-01T00:00:00Z</code></pre>



<p class="wp-block-paragraph">Two details worth knowing before you design around it. Validation only covers log files referenced by digest files, so a gap in digest coverage is a gap in your proof. And the files must stay where CloudTrail put them, which means copying logs into a tidier prefix breaks the built-in verification and hands you the job of writing your own checker.</p>



<p class="wp-block-paragraph">For evidence that is not CloudTrail, the equivalent is doing the work yourself: hash the artifact at the point of generation, record the hash somewhere separate from the artifact, and write both. A manifest per collection run, checksummed and locked alongside the files, is unglamorous and holds up.</p>



<p class="wp-block-paragraph">One constraint that catches people mid-build: a bucket with Object Lock enabled cannot be used as the destination for S3 server access logs. If your design had one bucket receiving both the evidence and its own access logs, it needs splitting.</p>



<h2 class="wp-block-heading">Failure five: the bill you cannot switch off</h2>



<p class="wp-block-paragraph">Compliance mode plus a long retention period plus archival storage classes is where cost surprises live, because every lever you would normally pull to reduce spend has been deliberately disabled.</p>



<p class="wp-block-paragraph">The mechanics that drive it, none of which are secret but all of which get discovered late:</p>



<ul class="wp-block-list">
<li><strong>Minimum storage durations.</strong> S3 Glacier Flexible Retrieval carries a 90 day minimum and S3 Glacier Deep Archive carries 180 days. Transition earlier than that and you are billed for the remainder of the minimum anyway.</li>



<li><strong>Per-object overhead in Glacier classes.</strong> AWS documents 40 KB of metadata overhead per object for Glacier Flexible Retrieval and Deep Archive, of which 8 KB is billed at S3 Standard rates and 32 KB at the destination rate. For a repository full of 2 KB JSON approvals, the overhead is the bill.</li>



<li><strong>Billing starts when the rule is satisfied.</strong> Lifecycle transitions to Glacier classes happen asynchronously, but you are charged at the destination rate, and the minimum duration clock starts, from the date the rule matched rather than the date the bytes physically moved.</li>



<li><strong>You cannot delete your way out.</strong> Under compliance mode, oversized or mistakenly retained objects stay until their retain-until date passes, whatever the finance conversation says.</li>
</ul>



<p class="wp-block-paragraph">The design response is to aggregate before you lock. Batch small artifacts into per-day or per-collection archives, checksum the archive, and lock the archive rather than thousands of tiny files. It costs you a little retrieval convenience and saves you a per-object overhead multiplier you cannot renegotiate later.</p>



<p class="wp-block-paragraph">Also pick the retrieval tier against the real deadline. Deep Archive is the cheapest place to keep evidence and one of the slowest to get it back. If a regulator or a legal team may need files at short notice, that trade is a bad one regardless of the storage line item.</p>



<h2 class="wp-block-heading">Putting the repository together</h2>



<p class="wp-block-paragraph">With the failure families in view, the build order is fairly short.</p>



<ol class="wp-block-list">
<li><strong>Separate the account.</strong> Evidence lives in its own AWS account, not a prefix in the production log bucket. The blast radius of a compromised production admin should not include the record of what they did.</li>



<li><strong>Write the service control policies first.</strong> At the organisation level, deny <code>s3:BypassGovernanceRetention</code>, deny <code>s3:PutBucketPolicy</code> and <code>s3:DeleteBucketPolicy</code> on the evidence bucket, and deny disabling the trail. SCPs apply to the account root too, which is the point.</li>



<li><strong>Create the bucket with Object Lock and a default governance retention.</strong> Block public access, enforce TLS with an <code>aws:SecureTransport</code> condition, and encrypt with a customer managed KMS key whose policy is written for this bucket alone.</li>



<li><strong>Enforce the retention floor in the bucket policy</strong> using the remaining-retention-days condition, so a misconfigured collector fails rather than writes something unprotected.</li>



<li><strong>Replicate to a second account or region.</strong> Object Lock works with S3 Replication, and the destination bucket must also have Object Lock enabled. This is your answer to region-level loss and to an account-level mistake.</li>



<li><strong>Add Vault Lock for backup-shaped evidence.</strong> Snapshots and database backups belong in a locked backup vault rather than being exported to S3 by hand.</li>



<li><strong>Alarm on the write path.</strong> A day with no new evidence objects is the failure that never pages anyone. Alert on absence, not just on errors.</li>
</ol>



<p class="wp-block-paragraph">Vault Lock deserves its own note because its compliance mode has a grace period that Object Lock does not. You choose a cooling-off window, which AWS requires to be at least three days, during which the lock can still be removed. After that the vault and its lock are immutable and cannot be changed or deleted by anyone, including AWS.</p>



<pre class="wp-block-code"><code># Compliance mode: the presence of --changeable-for-days is what makes it
# compliance rather than governance. Minimum value is 3 (72 hours).
aws backup put-backup-vault-lock-configuration 
  --backup-vault-name evidence-vault 
  --min-retention-days 365 
  --max-retention-days 2555 
  --changeable-for-days 3</code></pre>



<p class="wp-block-paragraph">Use that window. Run a backup job through the vault, confirm the retention arithmetic against what the policy document actually requires, and only then let the clock run out.</p>



<p class="wp-block-paragraph">One more design question worth asking out loud: does every copy need to be inside AWS? A second, offline-ish copy on independent infrastructure answers a different threat than cross-region replication does, because it survives an account-level compromise. Providers like InterServer, Contabo and Hetzner sell cheap storage that can hold an encrypted archive, and several S3-compatible services implement Object Lock semantics of their own. Confirm the provider actually supports Object Lock rather than assuming an S3-compatible API implies it, because that varies.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">AccessDenied when deleting a version you are allowed to delete</h3>



<p class="wp-block-paragraph">Usually governance retention plus a missing bypass. The IAM permission <code>s3:BypassGovernanceRetention</code> and the explicit bypass on the request are both required. If the object is in compliance mode, there is no combination of permissions that will work, and the error is telling you the truth.</p>



<pre class="wp-block-code"><code># Only valid for governance mode, and only with the matching IAM permission.
aws s3api delete-object 
  --bucket my-evidence-archive 
  --key path/to/object 
  --version-id "SOME_VERSION_ID" 
  --bypass-governance-retention</code></pre>



<h3 class="wp-block-heading">Replication fails on an Object Lock bucket</h3>



<p class="wp-block-paragraph">The destination needs Object Lock enabled too. Replicated objects take the lock configuration of the source, so a destination without Object Lock cannot accept them. Check the destination bucket first, then the replication role&#8217;s permissions.</p>



<h3 class="wp-block-heading">Lifecycle rule appears to do nothing</h3>



<p class="wp-block-paragraph">Two likely causes. The versions it targets are still under retention, so expiration cannot remove them. Or the rule targets current versions on a versioned bucket, in which case it is adding delete markers and the data is still there as noncurrent versions, still being billed.</p>



<h3 class="wp-block-heading">validate-logs reports invalid or missing files</h3>



<p class="wp-block-paragraph">Confirm nothing moved the objects. Validation requires the log and digest files to be in the location CloudTrail delivered them to, and the command runs per region. A copy job that reorganised the prefix layout is the usual culprit.</p>



<h3 class="wp-block-heading">Object Lock cannot be enabled on the bucket</h3>



<p class="wp-block-paragraph">Versioning has to be enabled first, and Object Lock cannot be turned off later. If the bucket is currently a server access log destination, that has to move before Object Lock will work for you at all.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Enabling Object Lock on an existing bucket and assuming the objects already in it are now protected. They are not, until retention is applied to each version.</li>



<li>Choosing compliance mode on day one to look thorough, then discovering the retention period was wrong.</li>



<li>Treating Object Lock as an access control. It is a floor under your permissions, not a replacement for them.</li>



<li>Leaving the bypass permission attached to a general administrator role because it was needed once during testing.</li>



<li>Locking millions of tiny objects into Glacier classes and paying per-object overhead on all of them.</li>



<li>Storing evidence in the same account as the systems it describes, which makes the evidence only as trustworthy as that account&#8217;s admins.</li>



<li>Never testing retrieval, so the first restore attempt happens under audit pressure with an unfamiliar Glacier restore workflow.</li>
</ul>



<h2 class="wp-block-heading">Best practices for an audit evidence repository on AWS</h2>



<ul class="wp-block-list">
<li>Governance mode by default, compliance mode only where an external requirement names the period. Write down which rule drove the choice, in the same repository.</li>



<li>Enforce the retention floor in the bucket policy so the write path fails loudly instead of writing unprotected objects.</li>



<li>Use legal hold, not extended retention, for investigations. Legal holds have no expiry and are removed explicitly, which is the right shape for something that ends on an unknown date.</li>



<li>Aggregate small artifacts into archives with a checksummed manifest before locking them.</li>



<li>Replicate to a second account with Object Lock enabled, and check replication metrics rather than assuming.</li>



<li>Rehearse retrieval quarterly, including the Glacier restore step, and keep the output as evidence of the rehearsal.</li>



<li>Monitor for absence of writes. Tools like Amazon CloudWatch, Grafana or Datadog are all fine here. The alert that matters is &#8220;no evidence arrived today&#8221;.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can I enable S3 Object Lock on a bucket that already exists?</h3>



<p class="wp-block-paragraph">Yes. S3 supports enabling Object Lock on existing buckets, and versioning must be enabled. The important caveat is that enabling it does not retroactively protect the objects already stored. You apply retention to those separately, per object or with S3 Batch Operations.</p>



<h3 class="wp-block-heading">What is the difference between governance mode and compliance mode?</h3>



<p class="wp-block-paragraph">Governance mode can be overridden by a principal holding <code>s3:BypassGovernanceRetention</code> who sends the bypass header. Compliance mode cannot be overridden by anyone, including the root user, and the retention period cannot be shortened or the mode downgraded.</p>



<h3 class="wp-block-heading">Does S3 Object Lock stop someone deleting the file?</h3>



<p class="wp-block-paragraph">It stops the protected version being destroyed. It does not stop a delete marker being placed on top, which hides the object from ordinary listings while the version remains. Keep delete permissions tight and list versions when you audit.</p>



<h3 class="wp-block-heading">Do I need Object Lock if I already have versioning and MFA delete?</h3>



<p class="wp-block-paragraph">They solve different problems. Versioning preserves history but a sufficiently privileged caller can still remove versions. Object Lock removes that possibility for the duration of the retention period, which is the property an auditor is asking about.</p>



<h3 class="wp-block-heading">How do I prove the evidence has not been modified?</h3>



<p class="wp-block-paragraph">For CloudTrail, enable log file integrity validation and keep the signed digest files with the logs, then run <code>aws cloudtrail validate-logs</code> for the period in question. For other artifacts, hash at generation time, store the manifest separately, and lock both.</p>



<h3 class="wp-block-heading">Can a lifecycle rule delete objects that are still locked?</h3>



<p class="wp-block-paragraph">No. Retention wins over lifecycle expiration. Plan the two together, because a cleanup rule that quietly does nothing for years is a cost problem you will not see until you look at storage growth.</p>



<h3 class="wp-block-heading">Is Vault Lock compliance mode reversible?</h3>



<p class="wp-block-paragraph">Only during the cooling-off window you set at creation, which must be at least three days. After the lock date, the vault and its lock cannot be changed or deleted by anyone, including AWS.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">An audit evidence repository on AWS fails in a specific, quiet way: the bucket says Object Lock is enabled, and the objects inside carry no retention. Everything else in this post is downstream of that gap. Check protection at the object version, not at the bucket, and enforce the floor in the write path so the system tells you when it is wrong.</p>



<p class="wp-block-paragraph">Then pick the mode like the one-way door it is. Governance mode with a locked-down bypass gets you almost all of the protection and keeps a documented way back. Compliance mode is worth its permanence when a real external rule demands it, and expensive theatre when it does not.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help building or reviewing one of these?</h2>



<p class="wp-block-paragraph">Immutable retention is easy to enable and easy to get subtly wrong, and the mistakes are the kind you find out about at the worst moment. This is work I take on directly:</p>



<ul class="wp-block-list">
<li>Designing an evidence account with Object Lock, KMS, service control policies and a locked-down write path</li>



<li>Auditing an existing bucket to find object versions that carry no retention despite Object Lock being enabled</li>



<li>Backfilling retention across existing objects with S3 Batch Operations without locking in the wrong period</li>



<li>Modelling the long-run cost of a retention decision before compliance mode makes it permanent</li>



<li>Setting up cross-account replication, AWS Backup Vault Lock and CloudTrail integrity validation as one coherent design</li>



<li>Writing the retrieval runbook and rehearsing it, so the first restore is not during an audit</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion on what you already have, send me the bucket policy, the Object Lock configuration and the output of a <code>head-object</code> call on one old evidence file. That is usually enough to tell you where the gap is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://john-nessime.com/projects/">See how I work on projects</a></div>



<div class="wp-block-button is-style-outline is-style-outline--1"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/audit-evidence-repository-aws/">Building an Audit Evidence Repository on AWS: Immutable Retention Without Regret</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</title>
		<link>https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 15 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Config]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Backfill]]></category>
		<category><![CDATA[Cost Allocation Tags]]></category>
		<category><![CDATA[Cost and Usage Report]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Multi-Account Strategy]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<category><![CDATA[Showback]]></category>
		<category><![CDATA[Tag Policies]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=519</guid>

					<description><![CDATA[<p>Tagging a resource and activating a cost allocation tag are two different operations, and AWS never tells you when you have only done the first. A practical guide to the activation gap, what backfill can and cannot repair, the five places coverage leaks, and a retrofit order that actually converges.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/">AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You tagged everything. The Terraform provider block carries <code>default_tags</code> with <code>Project</code> and <code>Environment</code>, every module inherits them, and you can open the console right now and see those tags sitting on the instances.</p>



<p class="wp-block-paragraph">Then someone in finance asks what the reporting pipeline costs. You open Cost Explorer, group by <code>Project</code>, and over half the bill lands in a bucket called &#8220;No tag key&#8221;.</p>



<p class="wp-block-paragraph">Nothing is broken. The tags are there. They just aren&#8217;t on the bill, because tagging a resource and activating a cost allocation tag are two separate operations, and AWS never warns you when you&#8217;ve done only the first.</p>



<p class="wp-block-paragraph">This post covers getting AWS cost allocation tags to cover a messy account: the activation gap, what backfill will and won&#8217;t repair, where coverage leaks even with decent discipline, and a retrofit order that converges.</p>



<h2 class="wp-block-heading">A resource tag and a cost allocation tag are not the same thing</h2>



<p class="wp-block-paragraph">A resource tag is metadata on the resource. A cost allocation tag is a billing dimension. Getting the second means taking a key that already exists on your resources and explicitly switching it on in the Billing and Cost Management console.</p>



<p class="wp-block-paragraph">Two details make this bite. First, activation lives in the management account. From a member account, the page tells you tags are managed at the payer level and that&#8217;s the end of it. Plenty of teams have a good tagging standard and no access to the one screen that makes it count. Related trap: an account moving between organizations has its activated keys reset to inactive.</p>



<p class="wp-block-paragraph">Second, the lag. AWS documents up to 24 hours for a newly applied key to appear on the activation page, and up to another 24 hours after you activate before it takes effect. The loop between &#8220;we agreed a standard&#8221; and &#8220;I can see it in Cost Explorer&#8221; is two days at best.</p>



<p class="wp-block-paragraph">Past a handful of keys, drive it from the API. Listing the inactive ones is the fastest way to find what you forgot:</p>



<pre class="wp-block-code"><code># Every key AWS knows about that is NOT currently billing-active
aws ce list-cost-allocation-tags --status Inactive

# Switch a set of them on
aws ce update-cost-allocation-tags-status --cli-input-json '{
  "CostAllocationTagsStatus": [
    {"TagKey": "Project",     "Status": "Active"},
    {"TagKey": "Environment", "Status": "Active"},
    {"TagKey": "Owner",       "Status": "Active"}
  ]
}'</code></pre>



<p class="wp-block-paragraph">User-defined keys appear in the report with a <code>user:</code> prefix and AWS-generated ones carry the reserved <code>aws:</code> prefix, so they never collide. There&#8217;s a cap on active keys, raisable through Service Quotas, which you almost certainly don&#8217;t need to raise.</p>



<h2 class="wp-block-heading">What the backfill repairs, and what it quietly doesn&#8217;t</h2>



<p class="wp-block-paragraph">Activation used to be strictly forward-looking, so activating late permanently orphaned everything before it. That&#8217;s no longer true. The management account can ask AWS to reapply current activation status across up to twelve months of historical cost data.</p>



<pre class="wp-block-code"><code># Must be the first day of a month, no earlier than twelve months back
aws ce start-cost-allocation-tag-backfill 
  --backfill-from 2025-01-01T00:00:00Z</code></pre>



<ul class="wp-block-list">
<li>Start date must be the first day of a month, and can&#8217;t reach back more than twelve months.</li>

<li>One request per 24 hours, and you can&#8217;t submit another while one is processing.</li>

<li>It backfills <em>activation status</em>, not tag values.</li>
</ul>



<p class="wp-block-paragraph">That last point catches people. If the resource wasn&#8217;t carrying the tag when the cost was incurred, backfill gives you nothing for those months. It fixes &#8220;I forgot to activate.&#8221; It does not fix &#8220;I forgot to tag.&#8221; A tag applied in June and activated in November backfills to June and no further, whatever start date you hand it.</p>



<p class="wp-block-paragraph">It also cuts both ways. Backfill applies the <em>current</em> status of every key, so a key sitting inactive right now gets stripped out of historical data too. If you&#8217;re pruning an overgrown tag list, backfill before you deactivate the old keys, not after, or you&#8217;ll erase the history you were trying to keep.</p>



<h2 class="wp-block-heading">Where AWS cost allocation tags actually leak coverage</h2>



<p class="wp-block-paragraph">With activation sorted and every Terraform-managed resource tagged, a stubborn slice of the bill still refuses to attribute. It matters which slice, because the fixes have nothing in common.</p>



<h3 class="wp-block-heading">Charges no resource can carry</h3>



<p class="wp-block-paragraph">Support charges, refunds, credits, tax, some Marketplace subscriptions. There&#8217;s no resource to hang a tag on, so tagging discipline never touches them. On accounts with a large support plan or heavy credit activity, that&#8217;s a visible fraction of the invoice.</p>



<p class="wp-block-paragraph">The answer is account tags. AWS added support for using AWS Organizations account tags as a cost allocation dimension, applying to all metered usage inside the tagged account and flowing into Cost Explorer, Budgets, Cost Anomaly Detection and Cost Categories. Because attribution happens at the account level, it picks up exactly what resource tags can&#8217;t reach.</p>



<p class="wp-block-paragraph">This is recent enough that most tagging guides predate it, and it&#8217;s the highest-value item here if your accounts already map roughly onto teams.</p>



<h3 class="wp-block-heading">Resources created by other resources</h3>



<p class="wp-block-paragraph">This is the quiet one, and usually the biggest surprise gap in an otherwise well-run account. Terraform&#8217;s provider-level <code>default_tags</code> applies to resources Terraform creates. It does not reach the EC2 instances an Auto Scaling group launches, because Terraform didn&#8217;t create those, the ASG did. Your ASG is tagged. Your instances aren&#8217;t. Same for volumes attached at launch, ENIs, snapshots and AMIs.</p>



<pre class="wp-block-code"><code>data "aws_default_tags" "current" {}

resource "aws_autoscaling_group" "app" {
  # ... other config ...

  # Push provider default tags down to launched instances
  dynamic "tag" {
    for_each = data.aws_default_tags.current.tags
    content {
      key                 = tag.key
      value               = tag.value
      propagate_at_launch = true
    }
  }
}

resource "aws_launch_template" "app" {
  # ... other config ...

  tag_specifications {
    resource_type = "instance"
    tags          = data.aws_default_tags.current.tags
  }

  tag_specifications {
    resource_type = "volume"
    tags          = data.aws_default_tags.current.tags
  }
}</code></pre>



<p class="wp-block-paragraph">For a standalone instance, <code>volume_tags</code> on <code>aws_instance</code> covers its attached volumes. General rule: whenever one AWS resource provisions another on your behalf, assume tags don&#8217;t propagate until you&#8217;ve checked the created resource in the console, not the plan output.</p>



<h3 class="wp-block-heading">Shared infrastructure that belongs to everyone</h3>



<p class="wp-block-paragraph">NAT gateway data processing, VPC interface endpoints, a Transit Gateway, a shared load balancer, the central logging bucket. You can tag all of these perfectly and the tag says <code>Project=platform</code>, which is accurate and useless for chargeback.</p>



<p class="wp-block-paragraph">Transit Gateway is worth calling out because it supports tagging the gateway resource itself, so data processing and transfer charges pick up the tag rather than only attachment hours. That gap used to swallow a lot of network spend.</p>



<p class="wp-block-paragraph">Beyond that, stop chasing tags and do allocation math instead. Cost Categories support split charge rules that redistribute a shared bucket across your other categories, using proportional splits, fixed percentages or an even division. Proportional is the sane default; fixed percentages are for when finance already agreed a number and the report has to match it.</p>



<h3 class="wp-block-heading">Container spend hiding behind one instance tag</h3>



<p class="wp-block-paragraph">A shared EKS or ECS cluster is one tagged compute footprint running fifteen teams&#8217; workloads. The instance tag gives you the total and nothing about who caused it. Split cost allocation data is the native answer: opt in and the cost and usage export gains rows breaking cluster cost down to individual pods or tasks. It won&#8217;t account for idle headroom you&#8217;re deliberately carrying, but it beats one line item labelled &#8220;the cluster&#8221;.</p>



<h3 class="wp-block-heading">Case drift and near-duplicate keys</h3>



<p class="wp-block-paragraph">Keys and values are case-sensitive. <code>Environment</code>, <code>environment</code> and <code>ENV</code> are three distinct keys producing three columns, each holding a partial and misleading picture. Tag Policies in AWS Organizations exist for this: define the key including its capitalization, define permitted values, and get an org-wide compliance report. Cheapest governance on this list, because it costs nobody a deploy.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A retrofit order that converges</h2>



<p class="wp-block-paragraph">Retrofits stall because people start at the bottom, sweeping untagged resources by hand while the creation path keeps producing more. This order avoids that.</p>



<ol class="wp-block-list">
<li><strong>Measure first.</strong> Group Cost Explorer by your intended key and record what percentage of spend falls into &#8220;No tag key&#8221;. That&#8217;s your baseline and your only honest progress metric.</li>

<li><strong>Cut the key set down.</strong> Three to five keys: owner, environment, and a project or cost centre. Coverage on five beats partial coverage on thirty.</li>

<li><strong>Activate immediately, even with terrible coverage.</strong> Both clocks start now: the activation delay and the rolling twelve-month backfill window. There&#8217;s no downside to activating a key that&#8217;s barely applied.</li>

<li><strong>Rank the gap by spend, not resource count.</strong> Sort untagged line items by cost descending. It&#8217;s usually a few NAT gateways, a couple of oversized databases and one forgotten bucket. Ten thousand untagged security groups cost nothing.</li>

<li><strong>Fix the creation path before the backlog.</strong> IaC defaults, launch template tag specifications, ASG propagation. Sweeping first means sweeping again next month.</li>

<li><strong>Sweep the stragglers in bulk.</strong> The Resource Groups Tagging API works across services and regions, which beats going service by service.</li>

<li><strong>Backfill, then re-measure.</strong> Compare against step one. If the number barely moved, you fixed the wrong resources.</li>
</ol>



<pre class="wp-block-code"><code># Find what already carries a Project tag, to diff against inventory
aws resourcegroupstaggingapi get-resources --tag-filters Key=Project

# Apply tags to a specific set of ARNs
aws resourcegroupstaggingapi tag-resources 
  --resource-arn-list arn:aws:rds:eu-west-1:111122223333:db:reporting-01 
  --tags Project=reporting,Environment=prod</code></pre>



<p class="wp-block-paragraph">Run it against a couple of ARNs and confirm in the console before looping over a list. Bulk tagging is trivially easy to point at the wrong account.</p>



<h2 class="wp-block-heading">Enforcing tags without becoming the tag police</h2>



<p class="wp-block-paragraph">Four mechanisms, in increasing order of blast radius. Most teams need the first three and should be careful with the fourth.</p>



<ul class="wp-block-list">
<li><strong>IaC defaults.</strong> Provider-level default tags plus required module variables. Cheapest, catches the majority, nobody thinks about it.</li>

<li><strong>CI validation.</strong> A linter rule that fails the pull request on missing required tags. The failure lands on the person who can fix it, when they can fix it.</li>

<li><strong>Detective controls.</strong> The AWS Config managed rule for required tags flags non-compliant resources without blocking anything. Tells the truth, breaks nothing.</li>

<li><strong>Preventive controls.</strong> A service control policy denying creation when a required key is absent, keyed on a null check against the request tag.</li>
</ul>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRDSCreateWithoutProjectTag",
      "Effect": "Deny",
      "Action": "rds:CreateDBInstance",
      "Resource": "*",
      "Condition": {
        "Null": {
          "aws:RequestTag/Project": "true"
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">The honest trade-off: service control policies are where tagging programmes go to die. Not every API supports tag-on-create, and not every legitimate workflow passes tags through the way you expect. Roll a broad deny across the org on day one and you break somebody&#8217;s deployment on day two, the ticket lands on you, and the policy is gone by day three along with everyone&#8217;s appetite for the project.</p>



<p class="wp-block-paragraph">Scope it to one service at a time, starting in sandbox. I&#8217;d rather run Config plus Tag Policies for a quarter and fix the top three offenders in the IaC. The deny is a finishing move, not an opening one.</p>



<h2 class="wp-block-heading">Measuring coverage so it doesn&#8217;t rot</h2>



<p class="wp-block-paragraph">Cost Explorer gives the headline number. For a per-service breakdown, query the cost and usage export in Athena. One caveat: tag column naming differs between export versions, older ones giving a flattened column per activated key and newer formats a map column. Run a <code>DESCRIBE</code> first rather than guessing.</p>



<pre class="wp-block-code"><code>-- Confirm the actual column names first
DESCRIBE your_cur_database.your_cur_table;

-- Then: untagged spend, ranked by service
SELECT   line_item_product_code,
         SUM(line_item_unblended_cost) AS untagged_cost
FROM     your_cur_database.your_cur_table
WHERE    your_project_tag_column = ''
  AND    line_item_unblended_cost &gt; 0
GROUP BY 1
ORDER BY 2 DESC
LIMIT    20;</code></pre>



<p class="wp-block-paragraph">That ranked list is your work queue; everything below the top twenty is noise. The list call also returns a last-used timestamp per key, the cleanest way to find keys nobody has applied to anything in months.</p>



<p class="wp-block-paragraph">If you&#8217;d rather not maintain the dashboard yourself, this is a fair place to buy rather than build. Grafana Cloud sits on Athena comfortably if you already run Grafana, and platforms like CloudZero, Vantage and nOps handle allocation modelling out of the box. Their real value is the shared-cost and commitment maths. Their real cost is another vendor between you and your billing data, and several price against a percentage of the spend they watch, so run that number first.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<ul class="wp-block-list">
<li><strong>The key isn&#8217;t in the activation list.</strong> It must exist on at least one resource first, and propagation takes up to a day. Past that, check you&#8217;re in the management account and the key matches exactly, including case.</li>

<li><strong>Activated, but everything still shows untagged.</strong> Give it a full billing cycle. A key switched on mid-month leaves the first half of that month untagged until you backfill.</li>

<li><strong>Tags stopped working after an account move.</strong> Activation doesn&#8217;t survive a move between organizations. Reactivate from the new management account, then backfill.</li>

<li><strong>Cluster costs still land on one team.</strong> Instance tags can&#8217;t split a shared cluster. Opt into split cost allocation data, or treat the cluster as a shared cost in a split charge rule.</li>

<li><strong>You&#8217;ve hit the active key limit.</strong> Almost always too many keys rather than a real need for a quota increase. Use last-used timestamps to find dead ones, and backfill before deactivating.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Assuming a tagged resource is a tagged cost. It isn&#8217;t until you activate the key.</li>

<li>Waiting for good coverage before activating. Activate first, improve coverage second, backfill third.</li>

<li>Deactivating stale keys and then backfilling, which wipes their history out of the data.</li>

<li>Measuring coverage by resource count instead of by spend.</li>

<li>Rolling out a broad tag-enforcing SCP in production before anyone has read a compliance report.</li>

<li>Trusting <code>default_tags</code> to reach resources Terraform didn&#8217;t directly create.</li>

<li>Putting anything sensitive in a tag value. Tags surface in billing exports that finance and third-party tools read.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep the mandatory set to three to five keys, with defined allowed values rather than free text.</li>

<li>Pin casing in the standard and enforce it with Tag Policies, because AWS won&#8217;t do it for you.</li>

<li>Tag accounts as well as resources. Account tags cover charges resource tags structurally cannot.</li>

<li>Treat coverage as a tracked percentage of spend with an owner and a monthly trend, not a project that finishes.</li>

<li>Accept an irreducible remainder. Chasing the last few percent by hand costs more than it returns.</li>

<li>Put allocation rules in Cost Categories rather than inventing tag values that encode a split.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are AWS cost allocation tags retroactive?</h3>



<p class="wp-block-paragraph">Partly. Activation applies going forward, but the management account can request a backfill reapplying current activation status across up to twelve months. The catch is that the resource must have carried the tag at the time, so backfill recovers late activation, never late tagging.</p>



<h3 class="wp-block-heading">Why don&#8217;t my tags appear in Cost Explorer?</h3>



<p class="wp-block-paragraph">In order of likelihood: the key was never activated in the Billing console; you&#8217;re looking from a member account; the activation delay hasn&#8217;t elapsed; or the key differs in case from the one you activated.</p>



<h3 class="wp-block-heading">How many cost allocation tags can I activate?</h3>



<p class="wp-block-paragraph">There&#8217;s a documented per-account cap on active keys, raisable through Service Quotas, plus a separate per-resource cap on total tags. Check current numbers in the AWS billing quotas docs. In practice the binding limit is human: past roughly a dozen keys, consistency collapses.</p>



<h3 class="wp-block-heading">Should I use account tags or resource tags?</h3>



<p class="wp-block-paragraph">Both, for different jobs. Account tags handle top-level attribution and cover untaggable charges like support, credits and refunds. Resource tags handle granularity inside an account. If your accounts already map cleanly to teams, account tags are the cheaper win and worth doing first.</p>



<h3 class="wp-block-heading">Is this worth doing on a small account?</h3>



<p class="wp-block-paragraph">Scale the effort to the shape of the bill. If you run a handful of services under one account and can name every resource from memory, account tags plus two resource keys is plenty. Worth noticing too that a fixed-price VPS from a provider like Contabo or InterServer has trivially perfect cost allocation, because the bill is one predictable line. Elastic infrastructure buys flexibility and pays for it in attribution work.</p>



<h3 class="wp-block-heading">How do I allocate shared costs like NAT gateways?</h3>



<p class="wp-block-paragraph">Not with tags. Put them in a Cost Categories split charge rule and redistribute proportionally across consuming categories. Tag the shared resource to identify the bucket, then let the rule do the allocation.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">AWS cost allocation tags fail silently, and that&#8217;s what makes them frustrating. Nothing errors. No alarm fires. Your resources look correctly tagged in every console you check, the bill quietly disagrees, and you find out when someone asks a question you can&#8217;t answer.</p>



<p class="wp-block-paragraph">So check one thing now: open the cost allocation tags page in your management account and see whether the keys you&#8217;ve been diligently applying are actually switched on. If they aren&#8217;t, activate them today, then backfill. That twelve-month window rolls, and every month you wait is history you can&#8217;t recover.</p>



<p class="wp-block-paragraph">Everything else here is incremental. Coverage is a percentage you improve, not a state you reach.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help getting your AWS bill to explain itself?</h2>



<p class="wp-block-paragraph">Cost attribution is unglamorous and easy to postpone until the quarter someone actually needs the numbers. If your account is already messy, it goes faster with someone who has untangled one before. I help with:</p>



<ul class="wp-block-list">
<li>Auditing tag coverage as a percentage of spend, broken down by service, so you know what you&#8217;re actually fixing</li>

<li>Designing a minimal tag schema and the Tag Policies to keep it consistent across an organization</li>

<li>Closing propagation gaps in Terraform and CloudFormation, around Auto Scaling groups, launch templates and attached storage</li>

<li>Setting up Cost Categories with split charge rules so shared infrastructure stops distorting every team&#8217;s number</li>

<li>Building cost and usage export pipelines in Athena, partitioned so the dashboard isn&#8217;t expensive to load</li>

<li>Planning a staged tag enforcement rollout that doesn&#8217;t break anyone&#8217;s deployment pipeline</li>
</ul>



<p class="wp-block-paragraph">Send me a Cost Explorer screenshot grouped by your main tag key, or the output of a list call against your active tags, and I&#8217;ll tell you where the gap actually is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/">AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>AI-Generated IAM Policies: Where the Hard Boundary Belongs</title>
		<link>https://john-nessime.com/blog/devops/ai-generated-iam-policies/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 13 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[AI-Assisted Coding]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloudsplaining]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Generative AI]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Parliament]]></category>
		<category><![CDATA[Permissions Boundaries]]></category>
		<category><![CDATA[Policy Generation]]></category>
		<category><![CDATA[Policy Validation]]></category>
		<category><![CDATA[Privilege Escalation]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<category><![CDATA[Trust Policies]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=858</guid>

					<description><![CDATA[<p>A generated policy with no wildcards can still hand over your account. Here are the three failure shapes that survive human review, why the review step is the wrong place to put your defence, and how to build a ceiling that holds regardless of what the model writes.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/ai-generated-iam-policies/">AI-Generated IAM Policies: Where the Hard Boundary Belongs</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The policy looked fine. No action wildcard, no resource wildcard, a Sid on every statement, formatting tidier than anything I write by hand. It came out of a chat window, went into a pull request, and got two approvals in about ninety seconds.</p>



<p class="wp-block-paragraph">It granted <code>iam:PassRole</code> on one role ARN and <code>lambda:CreateFunction</code> in the same document. That is not a wildcard and it does not read as administrative access. It is administrative access anyway, to whatever that role can do, for anyone who can write a Lambda function.</p>



<p class="wp-block-paragraph">This is the shape of the problem with AI-generated IAM policies. The failure is almost never the obvious one. Models have absorbed enough style guidance to avoid <code>"Action": "*"</code>, so what comes back passes the eyeball test and the linter and still hands over more than you meant to give. The dangerous output is the one that looks careful.</p>



<p class="wp-block-paragraph">This post covers what policy generation genuinely does well, the failure shapes that survive human review, and where the hard boundary belongs: not in the review step, which is fallible, but in a mechanism that cannot be talked out of its answer.</p>



<h2 class="wp-block-heading">What AI-generated IAM policies get right</h2>



<p class="wp-block-paragraph">Start with the honest case, because it is a strong one. IAM has thousands of actions across hundreds of services and the naming is inconsistent in ways nobody holds in their head. Some services use <code>Describe</code>, some use <code>Get</code>, some use both for different things. ARN formats vary per service and per resource type inside a service. Condition keys apply to some actions and not others.</p>



<p class="wp-block-paragraph">A model is good at that recall problem, and at shape work: splitting a monolithic statement into per-resource statements, adding conditions, converting an inline policy into Terraform. Paste an <code>AccessDenied</code> message and you get a plausible read of which layer refused. So the argument is not that the tooling is useless. It is that recall and reasoning about consequences are different capabilities, and IAM is a system where the second one protects you.</p>



<h2 class="wp-block-heading">The failure modes that survive review</h2>



<h3 class="wp-block-heading">Permissions that compose into something bigger</h3>



<p class="wp-block-paragraph">Every action is individually defensible. The escalation lives in the combination.</p>



<p class="wp-block-paragraph">The canonical case is <code>iam:PassRole</code> beside any service that runs code: Lambda, EC2, ECS, Glue. Passing a role to a compute service means your code executes with that role&#8217;s permissions, so your ceiling is not your own policy, it is whatever the most powerful passable role can do. Rhino Security Labs catalogued a long list of these chains and every one of them is built from permissions that look unremarkable alone.</p>



<p class="wp-block-paragraph">Another that catches people: <code>iam:CreatePolicyVersion</code> on a policy you are attached to. A new version normally needs to be set as default to take effect, but the create call accepts a flag that makes it default immediately, and that flag does not require <code>iam:SetDefaultPolicyVersion</code>. A permission that reads as &#8220;can update policy documents&#8221; reads to an attacker as &#8220;can write myself an admin policy.&#8221;</p>



<p class="wp-block-paragraph">Ask a model whether a policy grants admin and it will check the statements and say no, correctly, one at a time. Composition is the part it does not reliably do, and it is the part that matters.</p>



<h3 class="wp-block-heading">Actions that do not exist</h3>



<p class="wp-block-paragraph">Plausible action names that no service publishes. <code>s3:ListBucketContents</code> instead of <code>s3:ListBucket</code>. IAM accepts a policy containing an unrecognized action string because the document is syntactically valid, so nothing fails at attach time.</p>



<p class="wp-block-paragraph">What happens instead is worse. The application throws <code>AccessDenied</code> in production, somebody debugging under pressure decides the granular list must be wrong, and the fix is a service wildcard. The invented action does not grant too much. It causes a human to grant too much a week later, and by then nobody connects the two events.</p>



<h3 class="wp-block-heading">Resource ARNs that look constrained and are not</h3>



<p class="wp-block-paragraph">An ARN with real characters in it feels safer than a star. Often it is not. The common versions are a trailing wildcard placed one segment too high, and an action whose resource element the service ignores. <code>s3:ListAllMyBuckets</code> is account-scoped no matter what you put in <code>Resource</code>. Several <code>ec2:Describe</code> actions behave the same way. A policy can read as resource-scoped throughout and be account-wide for the actions that count.</p>



<p class="wp-block-paragraph">The related trap is the missing condition: cross-account trust policies without an external ID, bucket policies without <code>aws:PrincipalOrgID</code>, integration roles without <code>aws:SourceIp</code> where the caller has a stable egress address. Generated policies include conditions when you ask and omit them when you do not, because an absent constraint never produces an error.</p>



<h2 class="wp-block-heading">Four things the model cannot know</h2>



<p class="wp-block-paragraph">Some of this is not a capability gap you can prompt past. It is missing input:</p>



<ul class="wp-block-list"><li><strong>Which resources exist.</strong> A bucket name is a guess unless you supplied it. Guessed names either fail closed or, if the wildcard is loose enough, match something you never intended.</li><li><strong>Which resources are sensitive.</strong> Your backup bucket and your public assets bucket are indistinguishable strings. Sensitivity lives nowhere in the document.</li><li><strong>What else is attached.</strong> Effective permissions come from identity policies, resource policies, boundaries, SCPs and session policies together. Reviewing one document tells you very little.</li><li><strong>What the blast radius is.</strong> &#8220;Can delete objects&#8221; means one thing for a scratch bucket and another for the only copy of a client&#8217;s data.</li></ul>



<p class="wp-block-paragraph">The training material is also skewed. Public IAM examples, including plenty of vendor documentation and countless forum answers, are permissive because permissive examples work on the first try. A model that has read the internet has read a lot of over-broad policies presented as correct.</p>



<h2 class="wp-block-heading">Where the hard boundary belongs</h2>



<p class="wp-block-paragraph">Review is not a boundary. Review is a filter with a pass rate that drops the more policies you look at in a week, and generated policies are cheap to produce, so volume rises exactly when attention falls.</p>



<p class="wp-block-paragraph">The boundary has to be something that says no without reading the document. AWS gives you two mechanisms and they are not interchangeable.</p>



<p class="wp-block-paragraph">A <strong>permissions boundary</strong> is a managed policy attached to a user or role that caps what identity-based policies can grant it. It grants nothing itself. Effective permissions become the intersection of the identity policy and the boundary, so an identity policy allowing <code>iam:*</code> against a boundary that omits IAM produces no IAM access at all. If no boundary is attached, none is evaluated, so this is opt-in per identity.</p>



<p class="wp-block-paragraph">A <strong>service control policy</strong> sets the maximum available permissions for every principal in an account or organizational unit, and also grants nothing. It is the right home for things nobody in that account should ever do, and it applies whether or not somebody remembered to attach a boundary.</p>



<p class="wp-block-paragraph">The division I use: SCPs carry account-wide invariants, such as denying escalation-relevant IAM write actions to everyone outside the break-glass role and denying regions you do not operate in. Boundaries carry the per-workload ceiling, and they are what makes it safe to let a pipeline role create other roles. You can permit that role to create roles while requiring, through a condition on the boundary key, that every role it creates carries your boundary. The pipeline can then generate whatever policy it likes, because the ceiling is enforced at evaluation time rather than at review time.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">If the only thing between a generated policy and production is a person reading JSON, you do not have a boundary. You have a habit.</p>
</blockquote>



<h2 class="wp-block-heading">The gate I would actually run</h2>



<p class="wp-block-paragraph">Mechanical checks in a pipeline, failing the build, not a checklist somebody is supposed to remember.</p>



<ol class="wp-block-list"><li><strong>Lint for nonsense.</strong> Parliament catches unknown action names, resource formats that cannot match the action, and type mismatches. Cheapest way to catch an invented action before it becomes a wildcard.</li><li><strong>Run AWS policy validation.</strong> Access Analyzer knows service-specific rules a generic linter does not.</li><li><strong>Assert the escalation actions are absent.</strong> Name the actions you never want granted and fail if any appear. This is the check that maps to the composition failure.</li><li><strong>Compare against the current policy.</strong> For edits, check whether the proposed document grants access the existing one does not. This one is billed per call, so run it on changed policies rather than the whole repository.</li><li><strong>Scan for known risky patterns.</strong> Cloudsplaining scores policies against categories including privilege escalation, data exfiltration and resource exposure, and produces something you can hand to a client.</li></ol>



<pre class="wp-block-code"><code># 1. Lint. Exit status is non-zero when there are findings.
parliament --file policy.json

# 2. AWS-side validation
aws accessanalyzer validate-policy 
  --policy-document file://policy.json 
  --policy-type IDENTITY_POLICY

# 3. Fail if any escalation-relevant action is granted
aws accessanalyzer check-access-not-granted 
  --policy-document file://policy.json 
  --access actions="iam:PassRole","iam:CreatePolicyVersion","iam:AttachRolePolicy" 
  --policy-type IDENTITY_POLICY

# 4. Does the proposed policy grant more than the current one?
aws accessanalyzer check-no-new-access 
  --existing-policy-document file://current.json 
  --new-policy-document file://proposed.json 
  --policy-type IDENTITY_POLICY

# 5. Risk-categorised report
cloudsplaining scan-policy-file --input-file policy.json</code></pre>



<p class="wp-block-paragraph">Step three is the one to add first if you only add one. It turns &#8220;we reviewed it&#8221; into &#8220;the build fails.&#8221;</p>



<p class="wp-block-paragraph">Attaching the boundary is a single call, and it is the part people skip because the role already works without it:</p>



<pre class="wp-block-code"><code>aws iam put-role-permissions-boundary 
  --role-name app-deploy-role 
  --permissions-boundary arn:aws:iam::123456789012:policy/WorkloadCeiling</code></pre>



<h2 class="wp-block-heading">Generate from observed behavior instead</h2>



<p class="wp-block-paragraph">There is a better source of truth than a model&#8217;s guess about what an application needs, and it is the application. Access Analyzer policy generation reads CloudTrail events for a role over a chosen window and writes a policy from what was actually called. Know its limits before relying on it:</p>



<ul class="wp-block-list"><li>It analyzes up to ninety days of history and needs a trail already logging for the account, so a role that only exercises certain paths quarterly produces an incomplete policy.</li><li>Coverage is per-service. For some services it identifies individual actions; for others it can only tell you the service was used and prompts you to fill in the actions yourself.</li><li>It does not produce action-level detail for data events such as S3 object-level operations.</li><li><code>iam:PassRole</code> is not included in generated policies. If your workload needs it, a human adds it back by hand, so the most escalation-relevant line in the document is the one the generator did not write.</li></ul>



<p class="wp-block-paragraph">Behavior-derived policies also fail in a useful direction. A missing permission produces a denial you can see in CloudTrail, which beats a permission you did not know you granted.</p>



<h2 class="wp-block-heading">Arguments that do not survive contact</h2>



<ul class="wp-block-list"><li><strong>&#8220;We review every policy before merge.&#8221;</strong> Review quality is a function of volume and attention, and generation raises volume. Composition failures are also the hardest to spot by reading, because spotting them means knowing what other roles exist and what they can do.</li><li><strong>&#8220;Access Analyzer came back clean.&#8221;</strong> Validation checks the document. It does not know that the role ARN you may pass happens to have <code>AdministratorAccess</code> attached. Clean findings on a policy that grants a dangerous chain is the normal case, not an anomaly.</li><li><strong>&#8220;A permissions boundary is overkill for a small project.&#8221;</strong> Small projects are where this bites hardest. One account, one deploy role, no organization, nothing between a bad policy and everything you own. The boundary is one managed policy and one CLI call.</li><li><strong>&#8220;We will tighten it after launch.&#8221;</strong> Nobody does. If you genuinely intend to, drive it from Access Analyzer&#8217;s unused access findings so you have a work queue instead of an intention.</li><li><strong>&#8220;Just prompt the model to follow least privilege.&#8221;</strong> That changes the output surface, not the reasoning. Fewer wildcards, better statements, no evaluation of what the combination permits.</li></ul>



<h2 class="wp-block-heading">How I would decide</h2>



<p class="wp-block-paragraph">My rule is about which actions are in the document, not about who or what wrote it.</p>



<p class="wp-block-paragraph"><strong>Ship generated policies freely</strong> when the document touches only data-plane actions on named resources, the identity has a boundary, and the pipeline checks pass. Read a bucket prefix, publish to a topic, write to a table. That is most policies, and hand-writing them wastes your afternoon.</p>



<p class="wp-block-paragraph"><strong>Treat generated output as a first draft</strong> when the policy includes any IAM write action, any <code>PassRole</code>, any trust policy edit, any resource policy readable from outside the account, or any KMS key policy. Use it to save typing, then verify every statement against the service authorization reference yourself.</p>



<p class="wp-block-paragraph"><strong>Do not generate at all</strong> for the boundary policies and SCPs. Those are the mechanism. Something that constrains everything else should be short, hand-written, understood by whoever owns the account, and changed rarely.</p>



<p class="wp-block-paragraph">Two notes for small engagements. If you run application infrastructure on a VPS from a provider like Contabo or InterServer and pull AWS in for storage or mail, that server holds long-lived credentials somewhere your AWS controls do not reach, so scope its policy to a bucket prefix and attach a boundary before worrying about anything else. And if the credentials go to a SaaS connector, a marketing platform such as GoHighLevel or a payments integration, you are handing keys to a system whose behavior you cannot audit. Those are the policies to write by hand.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are AI-generated IAM policies safe to use in production?</h3>



<p class="wp-block-paragraph">For data-plane permissions on named resources, generally yes, provided the identity carries a permissions boundary and the policy passes automated validation. For anything involving IAM write actions, <code>PassRole</code>, trust policies or key policies, treat the output as a draft and verify each statement yourself. What matters is the blast radius of the actions, not the authorship.</p>



<h3 class="wp-block-heading">Why does a generated policy pass validation and still grant too much?</h3>



<p class="wp-block-paragraph">Validation examines one document in isolation. Privilege escalation usually comes from a combination of individually reasonable permissions, or from the interaction between the policy and something outside it, such as which roles are passable and what those roles can do. A document can be internally correct and still open a path.</p>



<h3 class="wp-block-heading">What is the difference between a permissions boundary and an SCP?</h3>



<p class="wp-block-paragraph">Both cap permissions and neither grants any. A boundary attaches to a single IAM user or role and limits what that identity&#8217;s policies can grant it. An SCP applies to every principal in an account or organizational unit and needs AWS Organizations. With one account and no organization, boundaries are what you have. With an organization, use both.</p>



<h3 class="wp-block-heading">Can IAM Access Analyzer replace writing policies by hand?</h3>



<p class="wp-block-paragraph">Partly. Generation from CloudTrail activity beats a guess because it reflects what the workload actually called. It is limited by the analysis window, by per-service coverage differences, by the absence of action-level detail for data events, and by <code>iam:PassRole</code> being excluded. Treat it as a strong draft that needs a human pass over anything the generator could not see.</p>



<h3 class="wp-block-heading">Which IAM actions should I block outright in a small account?</h3>



<p class="wp-block-paragraph">The ones that let a principal rewrite its own permissions or borrow another identity&#8217;s: creating and setting default policy versions, attaching and putting inline policies on users, roles and groups, creating access keys for other users, and passing roles to compute services. Deny them for everything except a break-glass identity, then grant them back deliberately where a workload genuinely needs one.</p>



<h3 class="wp-block-heading">How do I catch an invented action name before production?</h3>



<p class="wp-block-paragraph">Run a linter that carries the IAM action catalogue. Parliament flags unknown actions and exits non-zero when it finds anything, so it drops straight into a pipeline step. Catching it early matters less because the fake action is dangerous and more because the eventual fix for the denial tends to be a service-level wildcard.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">AI-generated IAM policies are not more dangerous because a model wrote them. They are more dangerous because they arrive faster than you can think about them, and because they arrive looking finished.</p>



<p class="wp-block-paragraph">So stop trying to make the review step better. Put the boundary somewhere that does not depend on anyone paying attention: a permissions boundary on every identity a pipeline can create or modify, an SCP denying the escalation actions to everyone who does not need them, and a build step that fails when a forbidden action shows up. Then let generation happen at whatever speed it wants.</p>



<p class="wp-block-paragraph">The policy approved in ninety seconds is not the failure. The absence of anything underneath it was.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help drawing that boundary in your account?</h2>



<p class="wp-block-paragraph">Most of the IAM work I take on is exactly this: an account that grew organically, permissions nobody wants to touch in case something breaks, and no ceiling underneath any of it. Things I can help with:</p>



<ul class="wp-block-list"><li>Auditing existing roles and policies for privilege escalation chains, including the passable-role paths that never show up in a single-document review</li><li>Designing and attaching permissions boundaries for pipeline, workload and human identities, including the delegation pattern that lets a deploy role create roles safely</li><li>Writing the SCP set for a small organization: escalation-relevant IAM actions, region restrictions, and protection for logging and billing resources</li><li>Adding Access Analyzer validation and custom policy checks to a CI pipeline so forbidden actions fail the build instead of relying on review</li><li>Replacing over-broad roles with policies generated from CloudTrail activity, then closing the gaps the generator cannot see</li><li>Scoping down credentials handed to third-party SaaS integrations and to servers outside AWS, where your account controls do not apply</li></ul>



<p class="wp-block-paragraph">If you want a second opinion on something specific, send me the policy JSON, the output of a failing <code>check-access-not-granted</code> run, or the role list from your account, and I will tell you what I would change and why.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/ai-generated-iam-policies/">AI-Generated IAM Policies: Where the Hard Boundary Belongs</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Choosing an AWS Region for a MENA Client: Latency Is the Last Question</title>
		<link>https://john-nessime.com/blog/cloud-computing/aws-region-for-mena/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 26 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Amazon CloudFront]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Control Tower]]></category>
		<category><![CDATA[AWS Regions]]></category>
		<category><![CDATA[Cross-Border Data Transfer]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[Data Transfer Costs]]></category>
		<category><![CDATA[Disaster Recovery]]></category>
		<category><![CDATA[MENA]]></category>
		<category><![CDATA[Multi-AZ]]></category>
		<category><![CDATA[Parameter Store]]></category>
		<category><![CDATA[PDPL]]></category>
		<category><![CDATA[Region Selection]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=548</guid>

					<description><![CDATA[<p>Latency is the constraint everyone reaches for first when choosing an AWS region for MENA clients, and it is almost always the one that matters least. Residency law eliminates candidates, service availability eliminates more, and latency only picks between whatever survives. A practical walkthrough of that filter, with the commands to check service availability and region opt-in status programmatically, the guardrail that keeps the decision true, and the things that break in the first few weeks after you commit.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-region-for-mena/">Choosing an AWS Region for a MENA Client: Latency Is the Last Question</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone says it on the kickoff call, usually in the first ten minutes. &#8220;Bahrain, right? It&#8217;s the closest one.&#8221; Everybody nods, the Terraform gets written, and six weeks later the client&#8217;s lawyer asks a question nobody prepared for: where exactly is the personal data of our Egyptian users sitting right now?</p>



<p class="wp-block-paragraph">That is the moment the region choice stops being a performance question and turns into an expensive one. Choosing an AWS region for MENA workloads looks like a latency problem and almost never is. Latency is the constraint you can measure in an afternoon and fix with a CDN. Residency is the constraint that can force you to rebuild.</p>



<p class="wp-block-paragraph">This post walks through the order I work in when picking an AWS region for MENA clients: which constraints bind hard, which ones bend, how to check service availability programmatically instead of trusting a table someone screenshotted, and what tends to break after the decision is made.</p>



<h2 class="wp-block-heading">Work the constraints in the order they bind</h2>



<p class="wp-block-paragraph">Region selection is a filter, not a scoring exercise. You are not hunting for the best average across four categories. You are eliminating regions that cannot work, in the order that matters, then picking from whatever survives.</p>



<p class="wp-block-paragraph">The order I use:</p>



<ol class="wp-block-list">
<li><strong>Residency and regulatory obligations.</strong> Not engineerable. Either the law permits the data to sit there or it does not.</li>



<li><strong>Service availability.</strong> Not engineerable cheaply. If the region lacks something your design needs, you change the design or change the region.</li>



<li><strong>Resilience and blast radius.</strong> Partly engineerable, bounded by which regions are near enough to pair with.</li>



<li><strong>Latency.</strong> Very engineerable. Edge caching, connection reuse and smaller payloads beat a few hundred kilometres of fibre most of the time.</li>
</ol>



<p class="wp-block-paragraph">Most guides run this list backwards, because latency is the fun part and the one with a nice map. Running it backwards is how you end up with a beautifully fast architecture that legal makes you move.</p>



<h2 class="wp-block-heading">Constraint one: residency, and the region that does not exist</h2>



<p class="wp-block-paragraph">Here is the thing that catches people who have only worked with EU or US clients. In Europe you can usually satisfy a residency requirement by picking a region in the right country. Across MENA, that option frequently is not on the table, because AWS does not have an infrastructure region in most MENA countries.</p>



<p class="wp-block-paragraph">Check the current region table before you promise anything. The list changes, and the geography column is what a regulator cares about, not the region&#8217;s marketing name. The programmatic version:</p>



<pre class="wp-block-code"><code># List every region your account can see, with opt-in status
aws account list-regions 
  --query 'Regions[*].[RegionName,RegionOptStatus]' 
  --output table

# Confirm the physical country AWS records for a region
aws ssm get-parameters-by-path 
  --path /aws/service/global-infrastructure/regions/me-central-1 
  --region us-east-1 
  --output table</code></pre>



<p class="wp-block-paragraph">That second command returns the metadata AWS publishes for the region, including a geolocation country code. Better source than a blog post, this one included, because it comes from the same data the console uses.</p>



<p class="wp-block-paragraph">One caution on the path itself: the <code>global-infrastructure</code> parameter tree is only queryable from a subset of regions, so pin <code>--region us-east-1</code> on those calls rather than assuming your default profile region will work.</p>



<h3 class="wp-block-heading">Announced is not the same as available</h3>



<p class="wp-block-paragraph">AWS announces regions well ahead of general availability, and those announcements get quoted back at you by sales teams and clients as though the capacity already exists. Treat an announcement as a roadmap item. If the region code does not appear in the table your account can query, you cannot deploy to it, and you certainly cannot write a residency clause around it. That gap matters in the Gulf specifically, where announced-but-not-yet-open regions have been part of the conversation for a while.</p>



<h3 class="wp-block-heading">Residency and localisation are not the same requirement</h3>



<p class="wp-block-paragraph">The word &#8220;residency&#8221; gets used for at least three different obligations, and clients rarely distinguish between them. Before you look at a single region, get the client&#8217;s counsel to tell you which one applies:</p>



<ul class="wp-block-list">
<li><strong>Hard localisation.</strong> The data must sit in-country, full stop. With no region in that country, no AWS region satisfies this and you are into a local provider, on-premises kit, or a hybrid split.</li>



<li><strong>Conditional transfer.</strong> Data may leave if a condition is met: regulator authorisation, an adequacy finding, explicit consent, a specific contractual basis. This is the common case, and it is more paperwork problem than architecture problem.</li>



<li><strong>Sector rules on top.</strong> Finance, health and government workloads usually add a supervisor with its own cloud rules, which may name approved jurisdictions or require notice before you move anything.</li>
</ul>



<p class="wp-block-paragraph">Egypt is the clearest illustration of why this matters to an engineer. Its Personal Data Protection Law and the executive regulations under it set up a regime where moving personal data out of the country generally needs a licence or permit from the supervisory authority, rather than a self-assessment you document internally and move on from. There is no AWS region in Egypt. So for an Egyptian client with personal data in scope, picking a region does not answer the residency question. It restates it: which foreign region will we be authorised to transfer into, and who is filing that application?</p>



<p class="wp-block-paragraph">I am not a lawyer and neither are you. What you can do is refuse to let the region be chosen before that answer exists, because picking first and asking later is how the rebuild happens.</p>



<h3 class="wp-block-heading">Enforce the decision in the account, not in the runbook</h3>



<p class="wp-block-paragraph">Once the approved regions are settled, stop treating them as a convention. A convention survives until someone opens the console in the wrong region at eleven at night.</p>



<p class="wp-block-paragraph">The mechanism is the <code>aws:RequestedRegion</code> condition key, applied either as an IAM policy or, better, as a Service Control Policy across the organisation so it cannot be edited away by an account admin. AWS Control Tower ships a managed version of the same idea as its Region deny control if you are running a landing zone.</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideApprovedRegions",
      "Effect": "Deny",
      "NotAction": [
        "cloudfront:*",
        "iam:*",
        "organizations:*",
        "route53:*",
        "support:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": [
            "me-central-1"
          ]
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">The <code>NotAction</code> block is the part people get wrong, and it is worth understanding rather than copying. Several AWS services are global with a single control-plane endpoint that lives in <code>us-east-1</code>. A blanket deny on everything outside your approved region catches those calls too, and locks you out of IAM. Exempting them by action prefix is what keeps the policy usable. Extend the list to match what the client actually runs, and test it in a sandbox account before it goes anywhere near production.</p>



<p class="wp-block-paragraph">One wrinkle worth checking: managed inference services can route requests to other regions within a geography to absorb load. Sensible availability feature, simultaneous residency problem. If generative AI is in scope, confirm the routing behaviour explicitly rather than assuming a request to a regional endpoint stays there.</p>



<h2 class="wp-block-heading">Constraint two: service availability, where the nasty surprises live</h2>



<p class="wp-block-paragraph">Not every AWS service exists in every region, and the gap is widest in the newest and smallest ones. This is the failure mode that bites hardest, because it is invisible during design and obvious during implementation. The diagram gets approved, the Terraform plan runs, and one resource type throws an error that reads like a permissions problem but is really a &#8220;this does not exist here&#8221; problem.</p>



<p class="wp-block-paragraph">Do not check this against the services page by eye. Check it in a script, at design time, against the same source AWS uses:</p>



<pre class="wp-block-code"><code># Is a given service available in a given region?
# An empty result means no.
aws ssm get-parameters-by-path 
  --path /aws/service/global-infrastructure/services/athena/regions/me-central-1 
  --region us-east-1 
  --output text

# Every service AWS records for a region
aws ssm get-parameters-by-path 
  --path /aws/service/global-infrastructure/regions/me-central-1/services 
  --region us-east-1 
  --query 'Parameters[].Name' 
  --output text

# The canonical list of service identifiers to test against
aws ssm get-parameters-by-path 
  --path /aws/service/global-infrastructure/services 
  --region us-east-1 
  --query 'Parameters[].Name | sort(@)' 
  --output text</code></pre>



<p class="wp-block-paragraph">The identifiers in that tree are short API names, not display names, which trips people up. Pull the full list and grep it rather than guessing how a service name is spelled.</p>



<p class="wp-block-paragraph">Two refinements make this useful rather than a party trick:</p>



<ul class="wp-block-list">
<li><strong>Write the check as a matrix, not a lookup.</strong> Put the services your design depends on in a file, loop over the candidate regions, print a grid. You want the gaps visible before any infrastructure code exists.</li>



<li><strong>Presence is not feature parity.</strong> The parameter tree says a service exists in a region. It says nothing about which instance families, engine versions, storage classes or model options are offered there, and newer regions routinely carry a narrower menu. Where the specific variant matters, check that service&#8217;s own regional documentation too.</li>
</ul>



<h3 class="wp-block-heading">Opt-in regions and the quiet failures they cause</h3>



<p class="wp-block-paragraph">Every AWS region introduced in recent years, including all of the Middle East regions, is disabled by default and must be enabled on the account before you can use it. Small piece of admin, disproportionate number of confusing failures.</p>



<p class="wp-block-paragraph">The failure shape is always the same: it works in your account and fails in the client&#8217;s, or works in the management account and fails in a member account created last week. Enablement is per-account and not instantaneous, so any automated account vending process has to handle it.</p>



<pre class="wp-block-code"><code>aws account get-region-opt-status --region-name me-central-1</code></pre>



<p class="wp-block-paragraph">Run that against every account in scope as a pre-flight check, not as a debugging step after a pipeline fails.</p>



<h2 class="wp-block-heading">Constraint three: resilience and concentration risk</h2>



<p class="wp-block-paragraph">Every AWS region carries at least three Availability Zones, and the Middle East regions are three-AZ regions. That is enough for a properly designed multi-AZ deployment, and multi-AZ should be your default for anything the client would notice going down.</p>



<p class="wp-block-paragraph">The subtlety is what you pair the region with. Cross-region DR inside MENA pairs two regions that sit closer together than a European or North American pair would, and that share more exposure to the same events: a cut on a common submarine route, a regulatory action, a physical incident. That is not an argument against a MENA pair. It is an argument for saying plainly what the DR plan protects against, because a nearby second region covers a regional AWS failure and not much wider than that.</p>



<p class="wp-block-paragraph">Two practical points on AZ handling that catch people:</p>



<ul class="wp-block-list">
<li><strong>AZ names are per-account.</strong> The letter suffix on a zone name does not map to the same physical zone across two different accounts. If you are coordinating placement between accounts, use the zone ID rather than the name. <code>aws ec2 describe-availability-zones --region me-central-1</code> returns both.</li>



<li><strong>Capacity is not uniform.</strong> Smaller regions have less headroom in specific instance families, and you can hit an insufficient-capacity error on a launch that would have succeeded in a large region. Design for instance-family flexibility, and if the workload is steady state, talk to the client about a capacity commitment rather than discovering the ceiling during a launch window.</li>
</ul>



<h2 class="wp-block-heading">Constraint four: latency, finally</h2>



<p class="wp-block-paragraph">By the time residency and service availability have done their work you often have one candidate left, and the latency question answers itself. When you do still have a choice, measure properly.</p>



<p class="wp-block-paragraph">Measuring from your own machine tells you about your own connection, which is not what you care about. Regional routing is often counter-intuitive, and traffic between two Gulf cities does not necessarily take the short path. You want a measurement from the network the users are actually on. Options, in rough order of how much I trust them:</p>



<ul class="wp-block-list">
<li><strong>Real user monitoring on an existing property.</strong> If the client already has a site or app with users in that market, instrument it and read the field data. Nothing synthetic beats this.</li>



<li><strong>A small instance in each candidate region, probed from the target country.</strong> Cheap, quick, measures the actual path rather than a claim about it.</li>



<li><strong>A commercial VPN endpoint in the target country</strong> when you have no presence there. NordVPN or Surfshark will sanity-check the relative ordering of two regions, but treat the absolute numbers with suspicion: you are measuring a path through the provider&#8217;s network, not a real user&#8217;s path.</li>



<li><strong>Published latency matrices.</strong> Fine for a first pass. Not evidence.</li>
</ul>



<h3 class="wp-block-heading">Split the problem before you optimise the region</h3>



<p class="wp-block-paragraph">Most of what users experience as slowness is not round-trip time to the origin. It is uncached static assets, chatty APIs, TLS handshakes on cold connections, and payloads bigger than they need to be. A CDN fixes a large share of that whatever region the origin sits in, and the edge network is far denser across MENA than the region footprint is. Amazon CloudFront is the obvious in-stack choice; Cloudflare is worth a look when the client wants DNS, WAF and edge in one place, or when the origin is not on AWS at all.</p>



<p class="wp-block-paragraph">The caveat matters though. Caching at the edge puts copies of responses in edge locations worldwide. For static assets that is fine. For authenticated responses carrying personal data it is a residency question of its own, and the answer is usually to scope caching tightly and leave dynamic personal responses uncached.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">What breaks after you have chosen</h2>



<p class="wp-block-paragraph">A short troubleshooting list for the first few weeks, drawn from the things that reliably go wrong.</p>



<ul class="wp-block-list">
<li><strong>Access denied on a service you definitely have permissions for.</strong> Check the region deny SCP first, then check whether the call is hitting a global endpoint you forgot to exempt. The error will not tell you an SCP is responsible.</li>



<li><strong>A certificate that will not attach to your distribution.</strong> Certificates used with CloudFront must be requested in <code>us-east-1</code> regardless of where the origin lives. This looks like a residency violation to a nervous auditor and is not one, since a certificate holds no personal data. Have the explanation ready.</li>



<li><strong>Terraform plans that differ between engineers.</strong> Someone has a different default region in their profile. Pin it in the provider block and stop relying on environment configuration.</li>



<li><strong>An unexpected data transfer line on the bill.</strong> Cross-region traffic is charged, and replication, backups and cross-region reads all generate it quietly. Get cost allocation tags working before the first full month closes so you can attribute it rather than argue about it.</li>



<li><strong>A service that exists but behaves differently.</strong> Quotas start lower in newer regions and some features lag. Check the quota console before assuming your code is wrong.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Choosing the region on the kickoff call, before anyone has asked counsel what the transfer basis is.</li>



<li>Treating proximity as the residency answer. A region in a neighbouring country is still a cross-border transfer.</li>



<li>Copying a region deny policy from a blog without adapting the global-service exemptions to the client&#8217;s real service inventory.</li>



<li>Checking service availability once at the start and never again as the design grows new components.</li>



<li>Forgetting the observability stack. Logs and metrics carry personal data more often than people expect, and a monitoring platform hosted in another jurisdiction is a transfer. Grafana Cloud and the AWS-native options both let you choose where data lands, so choose deliberately.</li>



<li>Assuming this has to be AWS at all. For a low-sensitivity workload with no compliance surface, a VPS from Contabo or InterServer may beat a thin footprint in an expensive region. Say so if it is true.</li>
</ul>



<h2 class="wp-block-heading">How I would pick an AWS region for MENA</h2>



<ol class="wp-block-list">
<li>Get the in-scope data classes in writing. Personal data, health data, financial records and anonymised telemetry carry different rules.</li>



<li>Ask counsel which residency shape applies and whether a sector supervisor sits on top. Eliminate the regions that fail it. If nothing survives, have the hybrid conversation now rather than in month four.</li>



<li>Run the availability matrix against the survivors and eliminate again. If more than one region is left, measure latency from the target country and pick.</li>



<li>Write down the choice and the reasoning, enable the region on every account in scope, and apply the guardrail that same week.</li>
</ol>



<p class="wp-block-paragraph">That last step is the one people skip. A region decision without a written rationale gets relitigated every time a new engineer joins, and a region decision without a guardrail quietly stops being true.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Which AWS region is best for a client in the UAE?</h3>



<p class="wp-block-paragraph">If in-country storage is required, the UAE region is the only AWS option that puts data inside the country. If the requirement is softer, Bahrain is a fair alternative and has been open longer, which usually means broader service coverage. Run the availability matrix against both before committing.</p>



<h3 class="wp-block-heading">Is there an AWS region in Egypt or Saudi Arabia?</h3>



<p class="wp-block-paragraph">Check the region table rather than trusting any article, this one included. AWS has publicly announced intent to build in the area and those announcements circulate widely, but only regions appearing in the table your account can query are deployable. Where there is no region, the real question is which nearby region you are permitted to transfer into.</p>



<h3 class="wp-block-heading">Does choosing a nearby AWS region satisfy MENA data residency law?</h3>



<p class="wp-block-paragraph">Not by itself. Several MENA data protection regimes treat any movement of personal data across national borders as a transfer needing a legal basis, and some require prior authorisation from a regulator. Geographic closeness carries no legal weight. Get the transfer basis confirmed before the region is fixed.</p>



<h3 class="wp-block-heading">How do I check whether a specific AWS service is available in a region?</h3>



<p class="wp-block-paragraph">Query the global infrastructure tree in Systems Manager Parameter Store. An empty result for a service and region pair means it is not offered there. It is the most reliable programmatic source, but it only tells you the service exists, not which features or instance types are available.</p>



<h3 class="wp-block-heading">Why do I get access denied in a Middle East region even as an administrator?</h3>



<p class="wp-block-paragraph">Two likely causes: the region is opt-in and has not been enabled on that account, or an SCP is denying requests outside an approved region list. Administrator permissions do not override an SCP, which is rather the point of one.</p>



<h3 class="wp-block-heading">How hard is it to change region later?</h3>



<p class="wp-block-paragraph">Harder than a move between accounts and much harder than people estimate. Every region-scoped identifier changes, stateful services need a data move with a cutover, and any regional endpoint baked into client configuration has to be coordinated. Budget it as a project, not a task. Which is exactly why the upfront decision deserves the care.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Choosing an AWS region for MENA clients is a legal decision with a technical implementation, not the reverse. Residency eliminates candidates, service availability eliminates more, latency picks between whatever survives. Run the filter in that order and you will sometimes land on a region that is not the closest one, which is fine. Run it backwards and you get a fast architecture in the wrong jurisdiction, which no amount of edge caching fixes.</p>



<p class="wp-block-paragraph">Get the transfer basis in writing, enforce the decision with a guardrail rather than a convention, and record why you chose what you chose.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help choosing or defending a region decision?</h2>



<p class="wp-block-paragraph">This is the kind of work where an outside opinion early saves a rebuild later. Things I can help with:</p>



<ul class="wp-block-list">
<li>Running a service availability matrix across candidate regions for your actual architecture, so the gaps surface before any infrastructure code is written</li>



<li>Writing and testing a region deny SCP with the global-service exemptions tuned to your service inventory, without locking anyone out</li>



<li>Building a repeatable latency measurement from your target markets rather than from a vendor&#8217;s map</li>



<li>Designing the CDN and caching split so static content goes to the edge and personal data does not</li>



<li>Reviewing an existing deployment for accidental cross-region data flows in backups, replication, logging and monitoring</li>



<li>Producing the written region rationale your client&#8217;s auditor or counsel is going to ask for</li>
</ul>



<p class="wp-block-paragraph">Send me the architecture diagram, a Terraform provider block, or the output of your region opt-in check, and I will tell you what I would look at first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-region-for-mena/">Choosing an AWS Region for a MENA Client: Latency Is the Last Question</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
