IAM Condition Keys for Agent Runtimes: Limit Blast Radius by Tag

0 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. The unfair advantage is IAM condition keys — especially tag-based and Bedrock-specific keys — so the runtime role physically cannot touch resources outside the tenant cell, even if the agent hallucinates a path.

⚡ TL;DR: Stamp every agent-touchable resource with TenantId / AgentScope tags. Bind the runtime role with aws:ResourceTag / aws:RequestTag / aws:PrincipalTag conditions. Prefer least-privilege Bedrock actions over *. Prove deny paths with chaos tests. Pair with Cell-Based Architecture on AWS, Chaos for IAM Boundaries, and Cursor Background Agents CI Gates.

The failure mode: app guards without IAM guards

// ❌ 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
{
  "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.

{
  "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.

{
  "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"
    }
  }
}
// ✅ 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.
// 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:

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).

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

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.