IAM for AI Agents: Condition Keys That Limit Blast Radius by Tag

13 views
An AI coding agent with bedrock:InvokeModel* and s3:* on * is a privilege escalation waiting for a prompt. Teams “fix” it with app-level path checks; attackers (or confused agents) call the SDK directly. Real IAM for AI agents uses condition keys — especially tag-based and Bedrock-specific keys — so the runtime role physically cannot touch resources outside the tenant cell.
⚡ TL;DR: IAM for AI agents starts with tagging: stamp every agent-touchable resource with TenantId / AgentScope. Bind the runtime role with aws:ResourceTag / aws:RequestTag / aws:PrincipalTag conditions. Prefer least-privilege Bedrock actions over *. Prove deny paths with chaos tests — do not trust prompt instructions as the security boundary.
Apply the same least-privilege story to AI code review bot IAM, Lambda tool sandboxes, and Bedrock Agents + EventBridge workers.

IAM for AI agents: why app guards without IAM fail

typescript
// ❌ App-only guard — agent can still call S3 with stolen/confused credentials
async function readRepoFile(tenantId: string, key: string) {
  if (!key.startsWith(`repos/${tenantId}/`)) {
    throw new Error("path escape");
  }
  return s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
}
If the model invents repos/other-tenant/... and your guard has a bug — or a tool bypasses the helper — IAM still allows it. Condition keys make that a hard 403.

Tag the world the agent can see

Convention that scales:
Tag key On Meaning
TenantId S3 objects (via bucket policy + Object tagging), DynamoDB items (via ABAC on leading key), secrets, queues Tenant boundary
AgentScope Tool Lambda versions, knowledge-base resources read / mutate / admin
Environment Everything dev / staging / prod
DataClass Buckets, tables public / customer / secrets
json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadOnlyTenantObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:GetObjectTagging"],
      "Resource": "arn:aws:s3:::cc-agent-repos-${Environment}/*",
      "Condition": {
        "StringEquals": {
          "s3:ExistingObjectTag/TenantId": "${aws:PrincipalTag/TenantId}",
          "aws:PrincipalTag/Environment": "${aws:ResourceTag/Environment}"
        }
      }
    },
    {
      "Sid": "DenyCustomerDataCrossEnv",
      "Effect": "Deny",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalTag/Environment": "${aws:ResourceTag/Environment}"
        },
        "StringEquals": {
          "aws:ResourceTag/DataClass": "customer"
        }
      }
    }
  ]
}
✅ Stamp the session role (or assumed role per run) with TenantId via sts:TagSession. ❌ Do not rely on a long-lived role shared across all tenants without principal tags.

Bedrock and agent-specific conditions

Bedrock supports condition keys that limit model IDs, guardrail usage, and more. Combine them so a coding agent cannot suddenly call an unrestricted foundation model or skip guardrails.
json
{
  "Sid": "ConverseOnlyApprovedModels",
  "Effect": "Allow",
  "Action": [
    "bedrock:InvokeModel",
    "bedrock:InvokeModelWithResponseStream",
    "bedrock:Converse",
    "bedrock:ConverseStream"
  ],
  "Resource": [
    "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-*",
    "arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-*"
  ],
  "Condition": {
    "StringEquals": {
      "aws:RequestedRegion": ["us-east-1", "us-west-2"]
    }
  }
}
For Agents / Knowledge Bases, scope bedrock:Retrieve and bedrock:InvokeAgent to resource ARNs tagged for that product cell — not *. When agents can start async work, constrain EventBridge events:PutEvents with events:detail-type and source conditions so they cannot inject arbitrary bus traffic (see Bedrock Agents Plus EventBridge).

Request tags on create — close the write path

Reads need ExistingObjectTag. Writes need request tags so agents cannot create untagged exfil buckets/objects.
json
{
  "Sid": "PutOnlyWithMatchingTenantTag",
  "Effect": "Allow",
  "Action": ["s3:PutObject"],
  "Resource": "arn:aws:s3:::cc-agent-repos-${Environment}/*",
  "Condition": {
    "StringEquals": {
      "s3:RequestObjectTag/TenantId": "${aws:PrincipalTag/TenantId}",
      "s3:RequestObjectTag/AgentScope": "mutate"
    }
  }
}
typescript
// ✅ Tool always forwards tags on write
await s3.send(
  new PutObjectCommand({
    Bucket: BUCKET,
    Key: `repos/${tenantId}/patches/${patchId}.diff`,
    Body: diff,
    Tagging: `TenantId=${encodeURIComponent(tenantId)}&AgentScope=mutate&Environment=${env}`,
  })
);
❌ Allowing s3:PutObject without tag conditions lets the agent create repos/evil/... without TenantId — your ABAC read path then becomes inconsistent.

Prove it with chaos and CI

App tests that mock S3 will never catch a missing condition. Add:
  1. Policy unit tests (IAM Access Analyzer policy validation / Cedar-like fixtures, or iam-lens style checks in CI).
  2. Live deny probes from a canary role assuming the agent role with a wrong TenantId tag — expect AccessDenied. See Chaos for IAM Boundaries.
  3. Agent PR gates that reject CDK/IAM diffs introducing Action: "*" or unconditioned s3:* — Cursor Background Agents CI Gates.
typescript
// canary/deny-probe.ts
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

export async function expectCrossTenantDenied() {
  const sts = new STSClient({});
  const assumed = await sts.send(
    new AssumeRoleCommand({
      RoleArn: process.env.AGENT_RUNTIME_ROLE_ARN!,
      RoleSessionName: "deny-probe",
      Tags: [
        { Key: "TenantId", Value: "tenant-A" },
        { Key: "Environment", Value: "prod" },
      ],
    })
  );
  const s3 = new S3Client({
    credentials: {
      accessKeyId: assumed.Credentials!.AccessKeyId!,
      secretAccessKey: assumed.Credentials!.SecretAccessKey!,
      sessionToken: assumed.Credentials!.SessionToken!,
    },
  });
  try {
    await s3.send(
      new GetObjectCommand({
        Bucket: "cc-agent-repos-prod",
        Key: "repos/tenant-B/secrets.env", // tagged TenantId=tenant-B
      })
    );
    throw new Error("❌ cross-tenant read unexpectedly allowed");
  } catch (e: any) {
    if (e?.name === "AccessDenied" || e?.$metadata?.httpStatusCode === 403) {
      return; // ✅
    }
    throw e;
  }
}
Also bind code signing so only CI-built agent runtimes run in prod — Lambda Code Signing.

Session tagging at assume time (STS)

The runtime shared role should be thin. Per request (or per agent run), assume a session role with tags:
typescript
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";

const sts = new STSClient({});

export async function credentialsForTenant(tenantId: string, env: string) {
  const out = await sts.send(
    new AssumeRoleCommand({
      RoleArn: process.env.AGENT_RUNTIME_ROLE_ARN!,
      RoleSessionName: `agent-${tenantId}`.slice(0, 64),
      DurationSeconds: 900,
      Tags: [
        { Key: "TenantId", Value: tenantId },
        { Key: "Environment", Value: env },
        { Key: "AgentScope", Value: "mutate" },
      ],
      TransitiveTagKeys: ["TenantId", "Environment"],
    })
  );
  return out.Credentials!;
}
✅ TransitiveTagKeys so downstream sts:AssumeRole chains cannot drop the tenant boundary. ❌ Passing tenant only as a header to tools while the AWS credential stays untagged.

Secrets and knowledge bases under the same ABAC story

Secrets Manager and Bedrock Knowledge Bases should carry the same TenantId tag story. An agent that can secretsmanager:GetSecretValue on * will pull prod DB creds “to debug.” Condition on secretsmanager:ResourceTag/TenantId and deny DataClass=secrets cross-env. For RAG, scope bedrock:Retrieve to KB ARNs that belong to the tenant cell — never a shared KB with mixed tenants unless document-level filtering is proven (prefer separate indexes per cell for strong isolation).

Related reading

Checklist

  • [ ] Per-run / per-tenant session tags via sts:TagSession (TenantId, Environment)
  • [ ] Allow statements use aws:ResourceTag / object tag conditions — not path-prefix hope
  • [ ] Put/create requires matching RequestObjectTag / aws:RequestTag
  • [ ] Bedrock limited to approved model ARNs + regions; no bedrock:* on *
  • [ ] Explicit Deny for cross-env customer data
  • [ ] Live AccessDenied canary in CI/CD for cross-tenant probes
  • [ ] Policy lint rejects unconditioned s3:* / dynamodb:* from agent roles

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.