Skip to content

IAM Policy Enforcement

AWSim ships with a real IAM policy evaluation engine (awsim-iam-policy) that implements AWS IAM authorization semantics. Enforcement is opt-in via the AWSIM_IAM_ENFORCE environment variable. By default, AWSim accepts credentials and policies but does not evaluate them (preserving backwards-compatible behavior).

This page covers the policy decision flow. For who can log into the admin UI and how SDK calls are required to carry a signed identity at all, see Operator authentication. The three gates (AWSIM_IAM_ENFORCE, AWSIM_REQUIRE_SIGNED_REQUESTS, AWSIM_REQUIRE_OPERATOR_AUTH) are independent and compose.

When enforcement is enabled, requests are evaluated against the full IAM decision flow: identity policies, resource policies, permissions boundaries, service control policies (SCPs), and session policies.

What It Evaluates

The engine models the AWS IAM policy evaluation logic:

  • Identity policies - inline and managed policies attached to the calling user, its groups, or the assumed role.
  • Resource policies - policies attached to the target resource (e.g. S3 bucket policy, KMS key policy).
  • Permissions boundaries - the maximum permissions a user or role can have.
  • Service control policies (SCPs) - organization-level allow/deny guardrails.
  • Session policies - inline policies passed to AssumeRole that further scope the session.

Decision order follows AWS: an explicit Deny anywhere wins; then SCPs must allow; then an Allow must be present in identity or resource policies; otherwise the request is implicitly denied.

Enabling Enforcement

Set the environment variable before launching AWSim:

bash
AWSIM_IAM_ENFORCE=true ./awsim

With Docker Compose:

yaml
services:
  awsim:
    image: awsim:latest
    environment:
      - AWSIM_IAM_ENFORCE=true
      - AWSIM_DATA_DIR=/data
    volumes:
      - ./data:/data
    ports:
      - "4566:4566"

When enforcement is off (default), every request is allowed regardless of policy - useful for rapid prototyping. Turn it on to unit-test IAM policies and negative paths in your IaC.

Who Enforcement Applies To

Enforcement keys off whether the request's access key is bound to an IAM user:

CallerTreatment
Access key bound to an IAM userFully enforced against that user's identity policies, permissions boundary, SCPs, session policy, and any resource policy
Access key not bound to any userTreated as an administrator
No credential at allDenied
AWSIM_ADMIN_ACCESS_KEYBypasses enforcement entirely (break-glass)

The second row is what makes enforcement usable. IAM is itself an enforced service, so if unknown keys were rejected there would be no way to create the first user: the calls needed to bootstrap would be denied, with no in-band recovery. Instead the documented quick-start key (test) works out of the box, and everything you actually create is enforced.

If you want unknown keys rejected rather than treated as administrative, that is authentication rather than authorization. Set AWSIM_REQUIRE_SIGNED_REQUESTS=true, which returns InvalidClientTokenId for a key that resolves to no IAM user. The two settings compose.

A denial from enforcement returns AccessDenied naming the action and resource. If you see InvalidClientTokenId instead, that is the signed-request gate, not policy evaluation.

Bootstrapping

Starting from a default instance, the sequence is:

  1. Start with AWSIM_IAM_ENFORCE=true. Your existing credentials keep working, because they are not yet bound to a user.
  2. Create users, policies and access keys using those credentials.
  3. Switch your client to a created user's access key. It is now enforced.

To recover from locking yourself out, restart with AWSIM_ADMIN_ACCESS_KEY set to a key you control, or without AWSIM_IAM_ENFORCE.

Quick Start

Create a user with an access key, attach an S3-restricted policy, and observe an explicit deny:

bash
# Start AWSim with enforcement on
AWSIM_IAM_ENFORCE=true ./awsim &

# Create user + access key
aws --endpoint-url http://localhost:4566 iam create-user --user-name alice
KEY=$(aws --endpoint-url http://localhost:4566 iam create-access-key --user-name alice)
AK=$(echo "$KEY" | jq -r '.AccessKey.AccessKeyId')
SK=$(echo "$KEY" | jq -r '.AccessKey.SecretAccessKey')

# Attach a narrow inline policy: only allow listing one bucket
aws --endpoint-url http://localhost:4566 iam put-user-policy \
  --user-name alice \
  --policy-name read-only-mybucket \
  --policy-document '{
    "Version":"2012-10-17",
    "Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::allowed-bucket"}]
  }'

# Try a request that SHOULD be allowed
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK \
  aws --endpoint-url http://localhost:4566 s3 ls s3://allowed-bucket

# Try a request that SHOULD be denied (PutObject is not in the policy)
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK \
  aws --endpoint-url http://localhost:4566 s3 cp README.md s3://allowed-bucket/
# => AccessDenied

The same call via curl (SigV4 signed - shown here with a placeholder signature for brevity):

bash
curl -s -X GET "http://localhost:4566/allowed-bucket?list-type=2" \
  -H "Authorization: AWS4-HMAC-SHA256 Credential=$AK/20260421/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=..."

Supported Condition Operators

All 26 AWS condition operators are implemented:

CategoryOperators
StringStringEquals, StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringLike, StringNotLike
NumericNumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEquals
DateDateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEquals
BooleanBool
BinaryBinaryEquals
IP AddressIpAddress, NotIpAddress (CIDR-aware, IPv4 + IPv6)
ARNArnEquals, ArnLike, ArnNotEquals, ArnNotLike
NullNull

Qualifiers

Each base operator may be wrapped with one prefix and/or one suffix:

  • ForAllValues:<op> - matches only when every value in a multi-valued context key satisfies the condition.
  • ForAnyValue:<op> - matches when at least one value in the context key satisfies it.
  • <op>IfExists - only applies the check when the context key is present; absent keys pass.

These compose: ForAllValues:StringEqualsIfExists is a valid operator.

Enforced Services

The gateway checks identity policies for every request, but only a subset of services register resource policy lookups and therefore participate in full resource-policy evaluation:

ServiceIdentity policyResource policy
S3YesBucket policies
DynamoDBYes-
KMSYesKey policies
SQSYesQueue policies
SNSYes-
Secrets ManagerYesResource-based policies
LambdaYesFunction policies
IAMYes-

Services not listed above are silently bypassed - the enforcement hook is not wired in yet, so requests succeed regardless of policy. This lets you roll out enforcement incrementally without breaking existing tests.

Wiring a Service

Enforcement is driven entirely from the gateway: for every request it asks the service handler for an action and a resource, and only when both are present does it run the policy check. Adding a service is therefore two trait methods on its ServiceHandler - no per-call-site plumbing:

rust
fn iam_action(&self, operation: &str) -> Option<String> {
    match operation {
        "CreateTable" => Some("dynamodb:CreateTable".into()),
        "PutItem" => Some("dynamodb:PutItem".into()),
        // ...one arm per guarded operation; unmapped ops return None
        _ => None,
    }
}

fn iam_resource(
    &self,
    operation: &str,
    input: &serde_json::Value,
    ctx: &RequestContext,
) -> Option<String> {
    // Derive the ARN the action targets, honoring the request context.
    let table = input["TableName"].as_str()?;
    Some(arn::build(ctx, "dynamodb", format!("table/{table}")))
}

The default impls return None, so an un-wired service is silently bypassed (its row is simply absent from the table above). Returning Some for an operation opts it into the check; AWSIM_IAM_ENFORCE=true then evaluates the caller's identity policies (and any registered resource policy) against that action/resource pair.

Two extras for services that need them:

  • iam:PassRole - when an operation accepts a RoleArn (Lambda CreateFunction, ECS RunTask, ...), call AuthzEngine::check_pass_role so the caller is checked for permission to pass that role to the service.
  • Resource policies - services with their own resource-based policies (bucket / key / queue / function policies) register a resource-policy lookup at startup so cross-account grants are evaluated. See S3ResourcePolicyLookup / LambdaResourcePolicyLookup for the pattern.

Policy Simulator API

SimulateCustomPolicy and SimulatePrincipalPolicy are no longer stubs - they run the real engine against the supplied policy documents and return one EvaluationResult per action/resource pair with EvalDecision set to allowed, explicitDeny, or implicitDeny.

bash
aws --endpoint-url http://localhost:4566 iam simulate-custom-policy \
  --policy-input-list '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::bucket/*"}]}' \
  --action-names s3:GetObject s3:PutObject \
  --resource-arns arn:aws:s3:::bucket/key

Returns two results: s3:GetObjectallowed, s3:PutObjectimplicitDeny.

Policy Validation

CreatePolicy, CreatePolicyVersion, PutUserPolicy, PutGroupPolicy, PutRolePolicy, CreateRole, and UpdateAssumeRolePolicy all parse their PolicyDocument / AssumeRolePolicyDocument input. A syntactically invalid policy now returns HTTP 400 with error code MalformedPolicyDocument:

Syntax errors in policy. unknown condition operator: StringEqualsFoo

Validation runs regardless of AWSIM_IAM_ENFORCE - a bad policy is always rejected at write time.

AssumeRole Trust Policies

sts:AssumeRole (and its WebIdentity / SAML variants) routes the calling principal through the target role's AssumeRolePolicyDocument. The trust policy is evaluated as a resource-based policy with the role ARN as the resource and these condition variables populated from the request:

  • sts:ExternalId - set from the ExternalId request parameter. Use with StringEquals to harden cross-account assumes against the confused-deputy attack.
  • aws:MultiFactorAuthPresent - true when the caller supplied both SerialNumber and TokenCode, false otherwise. Use with Bool to gate the role behind an MFA challenge.
  • aws:MultiFactorAuthAge - set to 0 when MFA is present. Use with NumericLessThan to enforce a recent challenge.
  • aws:SourceIp - taken from the request's client IP. Use with IpAddress to restrict the role to a known network.
  • aws:PrincipalArn / aws:PrincipalAccount / aws:SourceAccount - mirror the caller's identity for StringEquals checks.

A trust policy that declares any of these conditions rejects the assume request with AccessDenied when the condition isn't satisfied.

Not Yet Implemented

  • Session tags (aws:PrincipalTag/*, aws:ResourceTag/*) - parsed but empty context.
  • CloudTrail audit log - denied requests are not recorded.
  • IAM Access Analyzer - no public-access or external-principal findings.
  • VPC endpoint policies - not modeled; no VPC concept in AWSim.
  • Resource control policies (RCPs) - not evaluated.

See the IAM & STS service page for the operation catalog.

Released under MIT / Apache-2.0 License