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
AssumeRolethat 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:
AWSIM_IAM_ENFORCE=true ./awsimWith Docker Compose:
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:
| Caller | Treatment |
|---|---|
| Access key bound to an IAM user | Fully enforced against that user's identity policies, permissions boundary, SCPs, session policy, and any resource policy |
| Access key not bound to any user | Treated as an administrator |
| No credential at all | Denied |
AWSIM_ADMIN_ACCESS_KEY | Bypasses 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:
- Start with
AWSIM_IAM_ENFORCE=true. Your existing credentials keep working, because they are not yet bound to a user. - Create users, policies and access keys using those credentials.
- 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:
# 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/
# => AccessDeniedThe same call via curl (SigV4 signed - shown here with a placeholder signature for brevity):
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:
| Category | Operators |
|---|---|
| String | StringEquals, StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringLike, StringNotLike |
| Numeric | NumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEquals |
| Date | DateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEquals |
| Boolean | Bool |
| Binary | BinaryEquals |
| IP Address | IpAddress, NotIpAddress (CIDR-aware, IPv4 + IPv6) |
| ARN | ArnEquals, ArnLike, ArnNotEquals, ArnNotLike |
| Null | Null |
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:
| Service | Identity policy | Resource policy |
|---|---|---|
| S3 | Yes | Bucket policies |
| DynamoDB | Yes | - |
| KMS | Yes | Key policies |
| SQS | Yes | Queue policies |
| SNS | Yes | - |
| Secrets Manager | Yes | Resource-based policies |
| Lambda | Yes | Function policies |
| IAM | Yes | - |
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:
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 aRoleArn(LambdaCreateFunction, ECSRunTask, ...), callAuthzEngine::check_pass_roleso 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/LambdaResourcePolicyLookupfor 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.
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/keyReturns two results: s3:GetObject → allowed, s3:PutObject → implicitDeny.
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: StringEqualsFooValidation 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 theExternalIdrequest parameter. Use withStringEqualsto harden cross-account assumes against the confused-deputy attack.aws:MultiFactorAuthPresent-truewhen the caller supplied bothSerialNumberandTokenCode,falseotherwise. Use withBoolto gate the role behind an MFA challenge.aws:MultiFactorAuthAge- set to0when MFA is present. Use withNumericLessThanto enforce a recent challenge.aws:SourceIp- taken from the request's client IP. Use withIpAddressto restrict the role to a known network.aws:PrincipalArn/aws:PrincipalAccount/aws:SourceAccount- mirror the caller's identity forStringEqualschecks.
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.