<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Victor Okoroafor</title>
    <description>The latest articles on DEV Community by Victor Okoroafor (@okoroaforvic).</description>
    <link>https://dev.to/okoroaforvic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4173305%2F8fa9e583-4568-4280-aadd-cafec2723076.jpg</url>
      <title>DEV Community: Victor Okoroafor</title>
      <link>https://dev.to/okoroaforvic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/okoroaforvic"/>
    <language>en</language>
    <item>
      <title>How to Block Over-Privileged IAM Roles in AWS Using Policy-as-Code</title>
      <dc:creator>Victor Okoroafor</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:29:55 +0000</pubDate>
      <link>https://dev.to/okoroaforvic/how-to-block-over-privileged-iam-roles-in-aws-using-policy-as-code-1k8</link>
      <guid>https://dev.to/okoroaforvic/how-to-block-over-privileged-iam-roles-in-aws-using-policy-as-code-1k8</guid>
      <description>&lt;p&gt;Chances are, your AWS account has an over-privileged IAM role right now, and you may not know which one.&lt;/p&gt;

&lt;p&gt;When you build infrastructure, you write Terraform that declares your resources and assigns IAM roles to them. IAM controls who can do what in your account, so the principle of least privilege has to apply: an entity should get only the permissions it needs to do its job.&lt;/p&gt;

&lt;p&gt;That rule gets broken constantly, and the usual shortcut is a wildcard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"Action": "*"&lt;/code&gt; grants a function every permission it needs, plus every permission it doesn't. If that function is ever compromised, the attacker inherits everything that role can reach. One line in a policy file becomes your blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the check goes
&lt;/h2&gt;

&lt;p&gt;You can run &lt;code&gt;terraform apply&lt;/code&gt; from your laptop, but most teams run it through a CI/CD pipeline. A pipeline is a list of commands, and each one finishes with an exit code. Exit code 0 means the step passed and the pipeline continues. Any other exit code means the step failed and the pipeline stops.&lt;/p&gt;

&lt;p&gt;If you can make a security check fail with a non-zero exit code, you can halt the deployment. That's the whole idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy
&lt;/h2&gt;

&lt;p&gt;To block over-privileged roles, you write a policy in Rego. OPA, the policy engine, evaluates your Rego against an input document, which here is your Terraform plan converted to JSON. If your infrastructure code contains a wildcard where it shouldn't, OPA flags it.&lt;/p&gt;

&lt;p&gt;Here's the rule that does the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="ow"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="n"&gt;iam_policy_resources&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Does this field contain a wildcard? Handles a single string or a list.&lt;/span&gt;
&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;resource&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;iam_policy_resources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Statement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"IAM policy '%s' contains wildcard (*) action: violates least privilege"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A second rule does the same check for &lt;code&gt;Resource&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that bit me
&lt;/h2&gt;

&lt;p&gt;My first version of &lt;code&gt;has_wildcard&lt;/code&gt; only had the string check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="c1"&gt;# my first version: only catches Action = "*"&lt;/span&gt;
&lt;span class="n"&gt;has_wildcard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It caught &lt;code&gt;"Action": "*"&lt;/code&gt;. It missed &lt;code&gt;"Action": ["s3:GetObject", "*"]&lt;/code&gt;, which grants the same thing written differently. My policy let it through.&lt;/p&gt;

&lt;p&gt;The fix is the second &lt;code&gt;has_wildcard&lt;/code&gt; definition above. That &lt;code&gt;field[_]&lt;/code&gt; walks every element in the array instead of treating the array as a single value, so a wildcard sitting anywhere inside the list gets caught.&lt;/p&gt;

&lt;p&gt;That second definition is the difference between a policy that works and a policy that looks like it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a decision into a failed pipeline
&lt;/h2&gt;

&lt;p&gt;OPA only produces a decision. It doesn't decide whether your pipeline lives or dies.&lt;/p&gt;

&lt;p&gt;That's where Conftest comes in. Conftest runs on top of OPA and prints pass or fail the way a normal software test does.&lt;/p&gt;

&lt;p&gt;When Conftest finds a deny violation, it exits with code 1, and the GitHub runner stops. Here are the steps that matter in my workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terraform Plan&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./terraform&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform plan -out=tfplan&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Convert Plan to JSON&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./terraform&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform show -json tfplan &amp;gt; tfplan.json&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OPA Policy Validation (Conftest)&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./terraform&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;conftest test tfplan.json --policy ./policies/ --all-namespaces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy job is a separate job that waits for this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;terraform-apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-plan-and-validate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the validation job fails, the apply job never runs. Terraform never talks to AWS.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1jwljk04l2waypzcjrq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1jwljk04l2waypzcjrq.png" alt="GitHub Actions run showing the OPA Validation job failing. Conftest reports that IAM policy 'lambda_dynamodb' contains a wildcard action, violating least privilege. The step exits with code 1, and the Terraform Apply job is greyed out because it never ran." width="799" height="369"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;em&gt;Conftest failed the build. Exit code 1. Terraform never talks to AWS.&lt;/em&gt;



&lt;p&gt;GitHub Actions emails you when the job fails by default, and you can wire it to Slack with a webhook if that's where your team lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;I tested the policy against a deliberately over-privileged IAM role, one with &lt;code&gt;"Action": "*"&lt;/code&gt; in its policy document. The build failed, and the deployment never ran.&lt;/p&gt;

&lt;p&gt;The validation step added about 7.3 seconds to the pipeline. That's the cost of catching a wildcard before it reaches your account, and against a pipeline that already ran for over a minute, it was noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't catch
&lt;/h2&gt;

&lt;p&gt;The rule matches a bare &lt;code&gt;"*"&lt;/code&gt; only. A service-level wildcard like &lt;code&gt;"s3:*"&lt;/code&gt; still gets through, and catching it would need a pattern match rather than an exact string check.&lt;/p&gt;

&lt;p&gt;It also only guards what goes through the pipeline. A change made directly in the console bypasses it, which is why my project also uses AWS Config to catch configuration drift after deployment.&lt;/p&gt;

&lt;p&gt;My repo also excludes two platform policies, because AWS requires a wildcard resource for them. Real pipelines usually need a small exception list like that.&lt;/p&gt;

&lt;p&gt;There's a bigger failure mode I found later, one where the check passes when it shouldn't because it can't read the policy at all. I've written about it separately, because it deserves its own piece.&lt;/p&gt;

&lt;p&gt;I'd rather tell you where the gaps are than pretend the policy is complete. A rule you trust too much is worse than no rule at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;A bare wildcard in a policy that Terraform can fully resolve at plan time never reaches AWS through this pipeline. The pipeline decides, and the pipeline says no.&lt;/p&gt;

&lt;p&gt;That's the common case. It isn't every case, and I've written about the exception separately.&lt;/p&gt;

&lt;p&gt;The full implementation, with the Terraform, the Rego policies, and the GitHub Actions workflow, is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/vicGrey/aws-serverless-zero-trust-governance" rel="noopener noreferrer"&gt;github.com/vicGrey/aws-serverless-zero-trust-governance&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
