Articles

Writing an IAM Policy That's Actually Least Privilege: From Wildcards to Evidence

A two-action IAM policy looked perfectly scoped until a line-by-line read revealed a Claude model wildcard hiding behind a single action. This post shows how to write policies from code and CloudTrail evidence—and where IAM Access Analyzer can help or fall short.

Written by:
APin

Senior Technology Analyst • Verified Expert

More from this author
Writing an IAM Policy That's Actually Least Privilege: From Wildcards to Evidence

A two-action IAM policy looked perfectly scoped until a line-by-line read revealed a Claude model wildcard hiding behind a single action. This post shows how to write policies from code and CloudTrail evidence—and where IAM Access Analyzer can help or fall short.

From Pride to Panic: The Real Policy Behind a 'Two-Action' Agent

When drafting Identity and Access Management (IAM) policies, technical debt often manifests as a "cautious" over-provisioning of permissions. My initial policy for a simple agent—designed to read one Secrets Manager secret and invoke one Bedrock model—demonstrated this trap. While the Secrets Manager statement was correctly constrained to a single resource, the Bedrock statement used the wildcard anthropic.claude-* and a region-agnostic ARN, inadvertently granting access to the entire Claude model family across all regions.

The transition from a working policy to a secure one requires moving away from intuition-based "rounding up" and toward evidence-based definition. A robust policy lifecycle relies on two distinct sources of truth: the source code and historical telemetry.

The Discrepancy Between Intuition and Execution

The gap typically forms when developers prioritize convenience over specific resource scoping. My policy failed because I defined resources based on the conceptual "Claude family" rather than the specific model ID defined in my configuration. To enforce least privilege, developers must perform the following:

  • Code Audit: List every SDK method call made by the application.
  • Resource Mapping: Map each method to the specific, immutable resource ARN it requires.
  • Deny-by-Default: Remove all wildcards, specifically those targeting entire model families or broad regional scopes.

Validating Against CloudTrail

Relying on code audits alone can result in blind spots. IAM policy validation must be verified against actual API activity recorded in AWS CloudTrail. Using the lookup-events command, engineers can query for specific EventSource activity (e.g., bedrock.amazonaws.com) to observe the exact modelId requested by the runtime. If the logs confirm that a service role only ever requests us.anthropic.claude-sonnet-4-6, the policy resource field must be pinned to that specific ARN, regardless of the temptation to allow the whole family for "future-proofing."

While automated tools like IAM Access Analyzer are useful for generating policies based on activity, they have service-specific limitations. For services where Access Analyzer falls back to service-level templates—such as Bedrock—the developer must manually reconcile the policy against the requestParameters found in raw CloudTrail event logs. This manual reconciliation confirms that your policy is not just "good enough" for the current deployment, but is mathematically restricted to the absolute minimum required for the application to function.

Why 'Roughly Right' IAM Becomes a Guess in Disguise

Enterprise IAM policies often drift from the principle of least privilege not through a conscious decision to over-provision, but through the cognitive ease of intuition. When engineers write policies based on mental models—such as granting access to "the Claude model" instead of a specific ARN—they transition from evidence-based security to guessing. This practice creates a "fuzzy" security boundary that is significantly wider than the application's actual operational requirements.

The core risk lies in treating IAM resources as broad categories. For example, using a wildcard like anthropic.claude-* in a resource ARN grants the service principal access to every current and future model iteration matching that prefix. If the application code only invokes one specific model ID, every other model in that family becomes an unnecessary component of the attack surface. This is not least privilege; it is permissive configuration masquerading as security.

To move from "roughly right" to precise access control, engineers should adopt a process grounded in application behavior rather than broad service categories:

  • Audit by Execution: Do not rely on documentation or memory. Open the source code, extract the exact API calls made (e.g., secretsmanager:GetSecretValue), and map them to the specific resource ARNs requested.
  • Granular Resource Scoping: Replace wildcards with specific ARNs. If the code consumes one secret, define the policy for that single secret ARN. If the code calls one model, pin the resource to that specific model ID or inference profile.
  • Verify with CloudTrail: Since intuition is fallible, treat CloudTrail as the ground truth. Use the lookup-events CLI to query historical API activity. Analyze the requestParameters to confirm which resources were actually accessed versus what was authorized in the policy.
  • Automate Policy Generation: Utilize AWS IAM Access Analyzer to generate policy templates based on actual access activity. Note that while automated tools assist in determining the scope, they may fall back to service-level permissions for newer or specific API calls where resource-level data is unavailable.

Rigorous IAM maintenance requires treating policies as code that must be updated alongside the application. If a new model version is introduced, the policy should be explicitly updated to reflect that dependency, rather than relying on existing, over-permissive wildcards.

Grounding the Policy in Code: Every Call, Every Resource, No Exceptions

The policy must be written from a source-of-truth inventory of API calls in the code, not from SDK example policies or assumptions. In this project, deye_client.py and collector.py call get_secret_value() once at startup against a single secret name. The report step calls bedrock-runtime.invoke_model() with one model ID read from config. There is no code path requiring a second secret or model. Any permission beyond those exact calls is not least privilege; it is a guess wearing least privilege's clothes.

Scope each statement to the exact ARN the code touches. For Secrets Manager, the resource is one secret ARN. The trailing -* in an ARN like arn:aws:secretsmanager:us-east-1:111122223333:secret:solar-daily-brief/config-* only covers the random suffix Secrets Manager appends; it does not widen access to other secrets. For Bedrock, grant only the specific model ID or inference profile the code reads from config, such as arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-sonnet-4-6. Avoid family wildcards like anthropic.claude-*, which match every current and future Claude model. Also note that foundation-model ARNs are not account-scoped (arn:aws:bedrock:REGION::foundation-model/MODEL-ID), so a region wildcard silently grants access in every region Bedrock operates in.

Verify the policy against actual usage using CloudTrail. Query by event source and filter on the role's ARN to see every API action and resource that role touched over a period. In this project:

  • secretsmanager.amazonaws.com produced 102 GetSecretValue calls.
  • bedrock.amazonaws.com produced 58 InvokeModel calls; all 58 used the same modelId from config.

No granted action went unused. IAM Access Analyzer can generate least-privilege policies from CloudTrail activity, and AWS's IAM best practices guide recommends it, but it does not support action-level resource generation for Bedrock. For services with that gap, raw CloudTrail inspection remains the required check. Re-run the same queries after every deploy to keep the policy aligned with code.

Scoping by ARN: One Secret, One Model, One Region at a Time

The difference between these two IAM statements is the difference between evidence and intuition. The Secrets Manager statement grants one action against one resource: secretsmanager:GetSecretValue on arn:aws:secretsmanager:us-east-1:111122223333:secret:solar-daily-brief/config-*. The trailing -* is not a genuine wildcard; it only covers the random suffix Secrets Manager appends to every secret ARN. That statement matches exactly one existing secret.

The Bedrock statement slips: anthropic.claude-* matches Claude 3, 3.5, 3.7, Opus, and any future model sharing that prefix, while the code calls exactly one model ID read from configuration. The region wildcard bedrock:*::foundation-model makes the grant account-wide across every region where Bedrock operates, even if the runtime call originates in a single region. Foundation-model ARNs follow arn:aws:bedrock:REGION::foundation-model/MODEL-ID; they are not account-scoped, so the region field is the only place to constrain them.

The honest fix is to pin the resource to the exact model ID or inference profile the code reads from config, and accept a one-line policy change when upgrading models. That tradeoff already exists in the Secrets Manager statement, where the suffix handles rotation automatically.

  • Copy the Secrets Manager pattern: one Resource line, one ARN, one action.
  • Avoid family wildcards like anthropic.claude-*; they grant every current and future matching model.
  • Lock the region in the ARN where the format allows it; with bedrock:*::, you are granting across regions.

Verification belongs in the policy review loop: CloudTrail events show the exact API action and requestParameters.modelId for every call. IAM Access Analyzer can generate policies from that activity for supported services, but Bedrock is not on that list, so raw CloudTrail review remains necessary.

Check Reality with CloudTrail: What the Role Actually Did

CloudTrail records every API call with the exact action name and the resource it touched, which makes it the ground truth for what a role actually did. The first verification attempt filtered by Username=solar-brief and returned empty. For an assumed-role session, userName is not the role name; it is the session identity. Here that identity is CN=rpi-server, the certificate common name, because sts:SetSourceIdentity in the trust policy flows it into every event.

The correct approach filters by EventSource, then matches the role ARN inside each event's userIdentity.arn. Running aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventSource,AttributeValue=secretsmanager.amazonaws.com and selecting events whose userIdentity.arn matches the role returns GetSecretValue 102 times over seven weeks. Swapping the source to bedrock.amazonaws.com returns InvokeModel 58 times. Both actions are already in the policy; nothing granted sits unused.

Action names are not resources. Re-querying with .requestParameters.modelId instead of .eventName shows all 58 InvokeModel calls used the same model ID: us.anthropic.claude-sonnet-4-6. The policy's anthropic.claude-* resource grants the entire Claude model family. The action is verified and legitimate; the resource is verified and too wide.

Several calls also carried additionalEventData.inferenceRegion of us-east-2, although the API request was made in us-east-1. Cross-Region inference profiles execute the model elsewhere, but IAM authorizes the region where the API request was made, not the region where Bedrock executes behind it. Scoping the resource ARN to us-east-1 therefore matches every real call in the logs.

  • For assumed-role sessions, filter by EventSource and match the role ARN in userIdentity.arn; filtering by username returns empty.
  • Compare requestParameters.modelId against the resource ARN, not just the action name, to catch model-family wildcards.
  • Scope IAM by the API request region; additionalEventData.inferenceRegion reflects cross-Region execution, not IAM authorization.

Use IAM Access Analyzer—and Know Where It Won't Save You

IAM Access Analyzer operationalizes the least-privilege workflow that AWS's IAM security best practices guide names directly: "Use IAM Access Analyzer to generate least-privilege policies based on access activity." It consumes the same CloudTrail events you would query manually but replaces the hand-written lookup with a generation API. You call start-policy-generation with a principal ARN, a CloudTrail trail, and an evaluation window, and Access Analyzer returns a candidate policy built from the actions that role actually executed.

The value is that it closes the gap between what engineers remember writing and what the code does at runtime. In a representative case, a role read one Secrets Manager secret and invoked one Bedrock model. CloudTrail logs confirmed 102 GetSecretValue calls and 58 InvokeModel calls, every one targeting the same model ID: us.anthropic.claude-sonnet-4-6. The handwritten policy, however, granted the anthropic.claude-* family plus a region wildcard on the foundation-model ARN.

The limit is service coverage. AWS documents which services Access Analyzer can generate action-level, resource-aware policies for. Secrets Manager is on that list; Bedrock is not. For Secrets Manager, the generated policy would have returned a tightly scoped statement: one action, one secret ARN. For Bedrock, Access Analyzer falls back to service-level information—a template confirming the role used Bedrock and prompting you to supply actions and resources manually. It would not have caught the family wildcard.

That gap is why raw CloudTrail queries remain a required cross-check, not a replacement:

  • Use Access Analyzer as the baseline for services with action-level support, including Secrets Manager.
  • Query CloudTrail directly for services without it, filtering by EventSource and the role ARN, then grouping by eventName and requestParameters.modelId.
  • Repeat the query after every deploy; a one-time audit goes stale the next time the code changes.
  • Pin resources to the exact model ID or inference profile the code reads from configuration, not a prefix family.

Raw events also expose Bedrock-specific behavior: cross-region inference profiles may set additionalEventData.inferenceRegion to a region different from the API call, but IAM authorizes the region of the request itself. Scoping the ARN to the call region still matches all real traffic while removing unnecessary blast radius.

Editorial Policy & Research Methodology

Our findings are based on rigorous internal research, verified industry benchmarks, and direct technical implementation experience from our enterprise client projects. All statistics and technical claims are reviewed by senior engineers before publication to ensure accuracy, transparency, and helpfulness for our readers.

Have an Idea?

Let's Build Something Amazing Together.