Skip to main content

Command Palette

Search for a command to run...

Writing an IAM Policy That's Actually Least Privilege

Where I got the boundary right, and where my own policy is looser than it should be

Updated
11 min readView as Markdown
M
Cloud Security Architect | 12+ yrs in cybersecurity, hands-on with AWS since 2016. IAM · SIEM/SOAR · DevSecOps · Governance. Securing multi-account AWS across Latin America. Sharing real-world patterns with the AWS security community.

I wrote the IAM policy for my solar agent myself, on the first try, thinking I had done it right.

I had not, entirely.

The agent behind the last article does two things: it reads one secret from Secrets Manager, and it invokes a Claude model on Bedrock to write a daily report. Two actions. I sat down and wrote a policy for exactly those two actions, and I was proud of it, until I went back and actually read what I had granted, line by line, instead of what I remembered writing.

Here is the real, current policy on the role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SecretsManagerReadConfig",
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:solar-daily-brief/config-*"
    },
    {
      "Sid": "BedrockInvokeModel",
      "Effect": "Allow",
      "Action": "bedrock:InvokeModel",
      "Resource": [
        "arn:aws:bedrock:*::foundation-model/anthropic.claude-*",
        "arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-*"
      ]
    }
  ]
}

Read the first statement. One action, one secret, and the trailing -* only covers the random suffix Secrets Manager appends to every ARN. That statement grants access to exactly one thing that exists.

Read the second one. anthropic.claude-* matches every Claude model in the account's reach, not the one model the code actually calls. And the foundation-model resource starts with bedrock:*::, a region wildcard. My own agent, my own policy, and the two statements were not written with the same discipline.

That gap is the whole article.


The habit that produces this

Nobody sits down and decides to write a loose policy. It happens because writing IAM by intuition feels faster than writing it by evidence. You know roughly what the code does, so you grant roughly that, round up "to be safe," and move on. The Secrets Manager statement above worked because copying one ARN out of the console is trivial and there was only one secret to point at. The Bedrock statement went looser because I was not looking at one ARN, I was thinking in terms of "the Claude model," and anthropic.claude-* felt like a reasonable way to say that without hunting down which exact model ID the code calls.

The fix is not "try harder to remember correctly." It is to stop granting from memory and grant from what the code actually does.


What the code actually needs

The agent's own source settles the argument that intuition cannot. deye_client.py and collector.py never call anything in secretsmanager:* except a single get_secret_value() against one secret name, resolved once at startup and cached for the process lifetime. The report-writing step calls bedrock-runtime.invoke_model() with one model ID read from config, not a family, not a choice made at runtime. There is no code path in this project that needs a second secret or a second model. Whatever the policy grants beyond that is not least privilege, it is a guess wearing least privilege's clothes.

That is the exercise for any policy: open the code, list every AWS API call it makes, note the exact resource each call touches, and write the policy from that list. Not from the SDK docs' example policy, not from what "sounds right" for the service.


Scoping by ARN, one at a time

The Secrets Manager statement is the model to copy. One Resource line, one secret ARN, one action. If I add a second secret to this project next year, the policy grows by one line, not by a wildcard that quietly covers whatever else lands in that account's Secrets Manager.

The Bedrock statement is what it looks like when that discipline slips. anthropic.claude-* was meant to mean "whatever Claude model this project ends up using," a hedge against changing model versions. In practice it means the role can invoke Claude 3, Claude 3.5, Claude 3.7, Opus, every current and future model matching that prefix, when the code calls exactly one. The honest fix is to pin the resource to the specific model ID or inference profile the code reads from config, and accept that upgrading models later means a one-line policy change, the same tradeoff Secrets Manager's -* suffix already makes for you automatically.

The region wildcard on the foundation-model ARN deserves its own note, because it is easy to misread as sloppiness when it is partly a property of the ARN format. Foundation-model ARNs are not account-scoped, arn:aws:bedrock:REGION::foundation-model/MODEL-ID, so bedrock:*:: is one way people quietly grant a model across every region Bedrock runs in, not just us-east-1 where this agent actually calls it. Locking the region down where the ARN format allows it, and locking the model ID down everywhere, is the fix that costs nothing at runtime and removes real blast radius.


How I check the policy is enough, not extra

Writing from the code catches most of it. Verifying against real usage catches the rest, including the part I would not have caught by reading my own code, since I wrote both the code and the policy with the same blind spots. And this is the part that matters most: it has to be a check I can run again, not a one-time read of the code that goes stale the next time I touch it.

The ground truth is CloudTrail. Every call this role makes, whether it succeeds or gets denied, lands there with the exact API action name and the exact resource it touched. I do not need to guess or trust my memory of what the code calls, I can ask AWS what the role actually did.

My first attempt at the query filtered by Username=solar-brief, and it came back empty. The reason is worth a paragraph on its own: for an assumed-role session, the userName CloudTrail logs is not the role name, it is the session identity. And for this role, that session identity is not a random string, it is CN=rpi-server, the certificate's common name, because sts:SetSourceIdentity in the trust policy from the last article does exactly what I said it would there. The certificate's identity really does flow into every event. The right filter is the event source, then matching the role out of the full ARN in each event:

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventSource,AttributeValue=secretsmanager.amazonaws.com \
  --start-time 2026-06-01T00:00:00Z \
  --end-time 2026-08-15T00:00:00Z \
  --max-items 1000 \
  --query 'Events[].CloudTrailEvent' \
  --output text \
  | jq -s '[.[] | fromjson | select(.userIdentity.arn | test("solar-brief"))]
           | group_by(.eventName)
           | map({action: .[0].eventName, calls: length})'

Run against the real role, for the real seven weeks the query covers, that comes back as:

[
  { "action": "GetSecretValue", "calls": 102 }
]

One hundred two calls, every one of them GetSecretValue. Swap the EventSource for bedrock.amazonaws.com and run the same query:

[
  { "action": "InvokeModel", "calls": 58 }
]

Fifty-eight calls, all InvokeModel. Two actions total, both already in the policy's Action fields. Nothing granted sits unused, which rules out the lazy version of this problem, and the whole thing is repeatable: run it again after the next deploy and see whether the list grew.

But action names are not resources, and that is exactly where the Bedrock statement was hiding. Swap .eventName for .requestParameters.modelId in that same query, and every one of those 58 calls resolves to the same string: us.anthropic.claude-sonnet-4-6. One model, every time, for seven weeks straight. The policy's resource grants the entire anthropic.claude-* family. Same action, verified and legitimate. Wrong resource, verified and too wide.

One more thing the raw events showed, worth being honest about before I call the region wildcard pure waste: on several of those 58 calls, additionalEventData.inferenceRegion reads us-east-2, even though the call itself was made in us-east-1. Cross-Region inference profiles route the actual model execution somewhere else on Bedrock's side, a mechanism separate from IAM authorization. Scoping the resource ARN to us-east-1 still matches every real call in these logs, because IAM authorizes the region the API request itself was made in, not the region Bedrock happens to execute in behind it.

IAM Access Analyzer builds on the same CloudTrail data, and it is the method AWS's own IAM security best practices guide names directly: "Use IAM Access Analyzer to generate least-privilege policies based on access activity." It automates the next step, turning that activity into a candidate policy instead of a query you write by hand:

aws accessanalyzer start-policy-generation \
  --policy-generation-details principalArn=arn:aws:iam::111122223333:role/solar-brief \
  --cloud-trail-details '{
    "trails": [{"cloudTrailArn": "arn:aws:cloudtrail:us-east-1:111122223333:trail/management-trail", "regions": ["us-east-1"]}],
    "startTime": "2026-06-01T00:00:00Z",
    "endTime": "2026-08-15T00:00:00Z"
  }'

There is a real limit worth knowing before reaching for it. AWS documents which services Access Analyzer can generate action-level, resource-aware policies for. Secrets Manager is on that list. Bedrock is not. For secretsmanager, access-analyzer get-generated-policy would have handed back the tightly scoped statement directly. For bedrock, it falls back to service-level information: a template that confirms the role used Bedrock and prompts me to fill in the actions and resources myself. It would not have caught the family wildcard on its own. That gap is exactly why the raw CloudTrail query mattered here, not as a replacement for Access Analyzer, but as the only way to see the actual resource for a service Access Analyzer does not resolve that deeply yet. AWS's own policy-generation docs back that division of labor: they say plainly not to use policy generation for auditing, and to use CloudTrail for that instead. For a role that has been live longer, pairing Access Analyzer with the unused access findings covers the other direction: permissions granted that were never invoked at all.


The trust policy: the other side of access

Least privilege is not only which actions a role can take, it is who can become that role in the first place. The trust policy on solar-brief is the part I did get right without a second pass, because it is short enough to reason about directly:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "rolesanywhere.amazonaws.com" },
      "Action": [
        "sts:AssumeRole",
        "sts:TagSession",
        "sts:SetSourceIdentity"
      ],
      "Condition": {
        "ArnEquals": {
          "aws:SourceArn": "arn:aws:rolesanywhere:us-east-1:111122223333:trust-anchor/TRUST-ANCHOR-ID"
        }
      }
    }
  ]
}

It trusts one service, and only when the request carries the aws:SourceArn of one specific trust anchor. No IAM user can assume this role, no other trust anchor can vouch for it, and no account but mine is in the picture. The permissions policy answers "what can this role do." The trust policy answers "who gets to find out," and a tight permissions policy behind a loose trust policy is not least privilege either, it just moves the wildcard one hop earlier.


Where least privilege becomes friction

Being honest about the cost matters as much as being honest about the gap. A perfectly scoped policy, one model ARN, one region, one secret, breaks the moment any of those three things changes, and something on this project will change eventually: a model gets deprecated, I add a second sensor, the account gets restructured. Each of those is a policy edit, a deploy, and if I am not paying attention, a window where the agent fails closed because the new resource is not in the Resource list yet.

That is the real tradeoff, not a hypothetical one. A family wildcard on the model ID never breaks when Anthropic ships a new model. A pinned ARN does, on purpose, and forces me to notice and update it. I would still rather have the agent fail loudly on a permissions error than have it keep working silently under a grant three times wider than anything it uses. Least privilege is not free, it is a maintenance cost you choose to carry instead of a security cost you hope never gets exploited.


Least privilege is not a state a policy reaches and then keeps. It is a habit of going back to a policy you already trust, reading it the way an auditor would instead of the way you remember writing it, and cutting whatever is wider than the code beneath it needs today.

My Secrets Manager statement passed that read. My Bedrock statement did not, until I went and looked.

Next in this series: pulling the actual data this agent reports on, straight from the Deye Cloud API.