SigV4 verification and IAM enforcement
Opt-in security features: real SigV4 signature checking and IAM identity-policy evaluation with Condition block support.
By default, fakecloud parses SigV4 headers for routing but doesn't verify signatures, and stores IAM policies without evaluating them. That's the right default for "tests just work" — real signature verification and policy enforcement get in the way of the happy path.
When you explicitly need them, two orthogonal flags flip them on:
FAKECLOUD_VERIFY_SIGV4=true # or --verify-sigv4
FAKECLOUD_IAM=off|soft|strict # or --iam off|soft|strictThey're independent: you can turn on SigV4 verification without touching IAM, or the other way around.
The reserved root identity
The credential pair test/test (and any access key starting with test) is treated as the de-facto root bypass. It skips both SigV4 verification and IAM enforcement, matching the community convention that LocalStack and other emulators use for local development.
When either opt-in feature is enabled, fakecloud emits a one-time WARN at startup noting this bypass so you don't silently get false-positive "my policies work" results from unsigned test clients.
--verify-sigv4
When on, every incoming request is cryptographically verified:
- Canonical request rebuilt per the AWS SigV4 spec (each path segment of the request URI is decoded, then re-encoded twice for non-S3 services and once for S3, the way the signer built it, so S3 keys with spaces or
+and ARNs in REST paths verify; sorted, URL-encoded query string; lowercased + sorted headers; payload hash fromX-Amz-Content-Sha256when present, otherwisesha256(body)). - Signing key derived via the four-step HMAC chain
AWS4 -> date -> region -> service -> aws4_request. - Constant-time comparison against the signature the client sent.
- Clock skew window of ±15 minutes, matching AWS.
Verification failures return protocol-correct AWS errors before business logic runs:
| Failure | AWS error |
|---|---|
| Wrong signature | SignatureDoesNotMatch |
| Unknown or deactivated access key | InvalidClientTokenId |
| Expired STS temporary credentials | ExpiredToken |
| Clock skew > 15 min | RequestTimeTooSkewed |
| Malformed auth header | IncompleteSignature |
Header-based Authorization: AWS4-HMAC-SHA256 ... and query-string (presigned URL) signatures are both supported. STS temporary credentials from AssumeRole, GetSessionToken, and GetFederationToken are persisted per-request and verified against the secret key the client received when it called STS.
--iam off|soft|strict
Three modes, in order of aggressiveness:
off(default): policies are stored but never consulted. Zero behavior change from unconfigured fakecloud.soft: policies are evaluated and each deny is logged on thefakecloud::iam::audittracing target, but the request is allowed through. Useful for onboarding: you can see which statements would fire without breaking your test suite.strict: policies are evaluated and denied requests fail with a protocol-correctAccessDeniedExceptionbefore the service handler runs.
Filter the audit events with RUST_LOG=fakecloud::iam::audit=warn.
Root is always allowed
The account's IAM root identity (arn:aws:iam::<account>:root) and the reserved test* bypass AKIDs always pass enforcement, matching AWS's own behavior where root bypasses identity-based policies. The root exemption stops at the account boundary: root of one account reaching an S3 bucket or a DynamoDB table or stream that another account owns is a cross-account request, allowed only when the owner's bucket (or table/stream) policy grants it, as on AWS.
Credentials that resolve to no identity
Under strict, a request to an enforced service signed with an access key that resolves to no identity is rejected before any handler runs, even without --verify-sigv4: an unknown or deactivated (Inactive) key gets InvalidClientTokenId, an expired STS session ExpiredToken. Such a request is never treated as the account root (so AssumeRole cannot mint credentials for it). The exemptions are explicit: the reserved test* identity and truly anonymous (unsigned) requests, which are authorized against resource policies only. soft logs these requests to fakecloud::iam::audit and lets them through. Only --verify-sigv4 additionally proves the caller holds the key's secret.
iam:PassRole
A request that hands a role to a service also needs iam:PassRole on that role, evaluated for the caller like any other action, with iam:PassedToService set to the receiving service principal and iam:AssociatedResourceArn to the resource the role is attached to:
| Service | Operations and role parameters | iam:PassedToService |
|---|---|---|
| Lambda | CreateFunction / UpdateFunctionConfiguration Role; CreateCapacityProvider / UpdateCapacityProvider PermissionsConfig.CapacityProviderOperatorRoleArn | lambda.amazonaws.com |
| EventBridge Scheduler | CreateSchedule / UpdateSchedule Target.RoleArn | scheduler.amazonaws.com |
| S3 | PutBucketReplication Role | s3.amazonaws.com |
| SNS | CreateTopic / SetTopicAttributes delivery-status roles (<Protocol>SuccessFeedbackRoleArn, <Protocol>FailureFeedbackRoleArn); Subscribe / SetSubscriptionAttributes SubscriptionRoleArn | sns.amazonaws.com |
So a principal granted only lambda:CreateFunction cannot attach an arbitrary (say, admin) role to code it controls. As on AWS, a role can only be passed within its own account: a role ARN in another account is denied iam:PassRole whatever the caller's policies say.
Enforced services
Opt-in enforcement covers the services most commonly subject to real IAM policies:
| Service | Covered actions | Resource ARN shape |
|---|---|---|
| IAM | All 128 supported actions | arn:aws:iam::<account>:{user,role,group,policy,instance-profile,mfa,server-certificate,saml-provider,oidc-provider}/<name> |
| STS | All 8 supported actions | * (or RoleArn for AssumeRole*) |
| SQS | All 20 supported actions | arn:aws:sqs:<region>:<account>:<queue-name> |
| SNS | All 34 supported actions | Topic / subscription / platform-app / endpoint ARNs |
| S3 | All 74 supported actions | arn:aws:s3:::<bucket>[/<key>] (object actions include the key; bucket actions don't) |
| KMS | All 47 supported actions | arn:aws:kms:<region>:<account>:key/<key-id> (key-targeted actions) or * (account-level actions like CreateKey, ListKeys) |
| DynamoDB | All 58 supported operations | arn:aws:dynamodb:<region>:<account>:table/<name>, with /index/<name> for Query, Scan and contributor insights on an index, /backup/..., /export/... and /import/... for those operations, arn:aws:dynamodb::<account>:global-table/<name> for legacy global tables, and * for account-level listings. Batches need the batch action on every table they name; transactions need GetItem / PutItem / UpdateItem / DeleteItem / ConditionCheckItem on each item's table; PartiQL statements need PartiQLSelect / PartiQLInsert / PartiQLUpdate / PartiQLDelete; CreateTable with Tags or ResourcePolicy also needs TagResource / PutResourcePolicy; restores also need the data-plane actions on the target table |
| DynamoDB Streams | All 4 supported operations (dynamodb: prefix) | arn:aws:dynamodb:<region>:<account>:table/<name>/stream/<label>, or * for ListStreams |
| Lambda | All 88 supported actions (Invoke / InvokeWithResponseStream authorize as lambda:InvokeFunction) | arn:aws:lambda:<region>:<account>:function:<name> for function-scoped actions (ARN, partial-ARN and name:qualifier inputs normalize to the function), * for listings and account-level actions |
| EventBridge Scheduler | All 12 supported actions | arn:aws:scheduler:<region>:<account>:schedule/<group>/<name>, arn:aws:scheduler:<region>:<account>:schedule-group/<name>, the tagged ARN for tag operations, * for listings |
Other services are not enforced even with FAKECLOUD_IAM=strict. The startup log enumerates which services are enforced vs. skipped so you always know the current surface. If a service you need is missing, open an issue — the wiring is straightforward per-service.
Evaluator scope
The policy evaluator implements the essentials of AWS's identity-based policy evaluation. It deliberately stops short of the features listed below so you don't build false mental models from a half-evaluator.
Implemented
Effect: "Allow"/Effect: "Deny"with Deny precedence (any matching deny wins).Action/NotActionwith*and?wildcards. Service prefix match is case-insensitive; action names are case-sensitive (matches AWS).Resource/NotResourcewith*and?wildcards.- Policy variables in
Version: "2012-10-17"policies:${aws:username},${aws:userid},${aws:PrincipalTag/<key>}and any other single-valued condition key, in the resource part ofResource/NotResourceARNs and inString*/Arn*condition values.${key, 'default'}supplies a default;${*},${?}and${$}stand for a literal*,?and$; a substituted value is literal (a*in a tag is not a wildcard). A variable with no value matches no resource, fails positive operators and satisfies inverted ones (StringNotEquals,StringNotLike,ArnNotLike, ...). Policies with any otherVersionread${...}literally, as AWS does. Conditionblocks: all 28 operators AWS defines, plus the...IfExistssuffix and theForAllValues:/ForAnyValue:qualifiers. See the next section for details.- Identity policies attached to:
- IAM users (inline + managed + via group membership, inline and managed)
- IAM roles (inline + managed, for assumed-role sessions)
- Managed policies resolve from both customer-managed policies you create and a built-in catalog of common AWS-managed policies (
arn:aws:iam::aws:policy/*, e.g.AdministratorAccess,AmazonS3FullAccess,AWSLambdaBasicExecutionRole). Attaching an AWS-managed policy grants its permissions just like a customer-managed one. - Empty effective policy set -> implicit deny.
Condition block evaluation
A statement with a Condition block only applies when every entry in the block evaluates to true against the request-time context. Multiple operators inside a single Condition are AND-combined; multiple keys inside one operator are AND-combined; multiple values for one key are OR-combined (modulo the ForAllValues qualifier).
Supported operators:
| Category | Operators |
|---|---|
| String | StringEquals, StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringLike, StringNotLike |
| Numeric | NumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEquals |
| Date | DateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEquals (RFC3339 and epoch seconds both accepted) |
| Boolean | Bool |
| Binary | BinaryEquals |
| IP address | IpAddress, NotIpAddress (v4 and v6 CIDR, plus bare addresses) |
| ARN | ArnEquals, ArnNotEquals, ArnLike, ArnNotLike |
| Existence | Null |
Every operator supports the ...IfExists suffix (missing key evaluates to true) and the ForAllValues: / ForAnyValue: set-qualifier prefixes. As on AWS, ForAllValues is also true when the request carries no values for a key the service populates (every one of zero values matches), while ForAnyValue then evaluates to false. When a key is absent from the request, positive operators (StringEquals, ArnLike, IpAddress, ...) evaluate to false and the negated operators (StringNotEquals, StringNotEqualsIgnoreCase, StringNotLike, NumericNotEquals, DateNotEquals, NotIpAddress, ArnNotEquals, ArnNotLike) evaluate to true, as on AWS, so a Deny guarded by StringNotEquals cannot be dodged by leaving the key out. ForAnyValue: with a negated operator still needs a value and evaluates to false. A condition operator fakecloud does not recognize (AWS rejects such a policy when you create it) fails closed in both directions: the Allow statement it guards does not apply, while the Deny statement it guards does.
Supported global condition keys:
| Key | Source |
|---|---|
aws:username | Last segment of the IAM user ARN; unset for assumed-role / federated principals, matching AWS |
aws:userid | Principal.user_id (e.g. AIDA..., AROA...:<session>) |
aws:PrincipalArn | Full principal ARN |
aws:PrincipalAccount | 12-digit account ID sourced from the credential (#381 alignment), not global config |
aws:PrincipalType | PascalCase label: User / AssumedRole / FederatedUser / Account |
aws:SourceIp | Remote address of the incoming HTTP connection |
aws:CurrentTime | Server-side evaluation timestamp (UTC) |
aws:EpochTime | Same moment as aws:CurrentTime, in seconds since the Unix epoch |
aws:SecureTransport | true iff the request carries x-forwarded-proto: https (the fakecloud server itself speaks HTTP; set this header from an upstream TLS terminator to test) |
aws:RequestedRegion | Region extracted from SigV4 / config |
aws:ResourceAccount | Account that owns the target resource (an S3 bucket's owner, the account in any other ARN) |
aws:PrincipalOrgID / aws:PrincipalOrgPaths | The caller's organization ID and the account's path (o-xxx/r-xxx/ou-xxx/.../); absent when the caller's account is in no organization, as on AWS |
aws:SourceArn / aws:SourceAccount | Set on requests fakecloud makes as an AWS service principal (see below) |
Supported service-specific condition keys:
| Key | Populated on | Source |
|---|---|---|
s3:prefix | s3:ListObjects, s3:ListObjectsV2 | ?prefix= query param |
s3:delimiter | s3:ListObjects, s3:ListObjectsV2 | ?delimiter= query param |
s3:max-keys | s3:ListObjects, s3:ListObjectsV2 | ?max-keys= query param |
s3:x-amz-acl | any request carrying the header (CreateBucket, PutObject, CopyObject, CreateMultipartUpload, PutBucketAcl, PutObjectAcl) | x-amz-acl header |
s3:x-amz-grant-read, -write, -read-acp, -write-acp, -full-control | any request carrying the header | matching x-amz-grant-* header |
s3:x-amz-object-ownership | any request carrying the header, which in practice is CreateBucket | x-amz-object-ownership header. AWS defines this key for CreateBucket only; PutBucketOwnershipControls carries the value in its XML body rather than a header, so no key is populated there |
s3:x-amz-server-side-encryption, s3:x-amz-server-side-encryption-aws-kms-key-id, s3:x-amz-server-side-encryption-customer-algorithm, s3:x-amz-storage-class, s3:x-amz-metadata-directive, s3:x-amz-copy-source | any request carrying the header (PutObject, CopyObject, CreateMultipartUpload) | matching request header, so guardrails like DenyIncorrectEncryptionHeader admit compliant writes |
sns:Protocol | sns:Subscribe | Protocol request parameter |
sns:Endpoint | sns:Subscribe | Endpoint request parameter |
lambda:FunctionArn | lambda:AddPermission | Target function ARN resolved from the path |
lambda:Principal | lambda:AddPermission | Principal field from the JSON body |
sqs:MessageAttribute.<Name> | sqs:SendMessage | Each named MessageAttribute's StringValue (Binary / Number attributes fall back to the data type) |
dynamodb:LeadingKeys / dynamodb:FirstPartitionKeyValues | GetItem, PutItem, UpdateItem, DeleteItem, ConditionCheckItem, Query, BatchGetItem, BatchWriteItem, PartiQL* | Partition-key values of the items addressed: the item key, a Query's partition-key equality (the index's partition key for an index query), every key or item a batch or transaction sends to the table, a PartiQL WHERE partition-key equality or inserted item |
dynamodb:Attributes | Item actions, Query, Scan, batches, PartiQL* | Top-level attribute names the request names: key / item attributes, ProjectionExpression, AttributesToGet, and every attribute an update, condition, filter or key-condition expression (or the legacy parameters) references |
dynamodb:Select | GetItem, Query, Scan, BatchGetItem, PartiQLSelect | Select, else SPECIFIC_ATTRIBUTES with a projection, ALL_PROJECTED_ATTRIBUTES for an index query, ALL_ATTRIBUTES otherwise |
dynamodb:ReturnValues | PutItem, UpdateItem, DeleteItem | ReturnValues, NONE by default |
dynamodb:ReturnConsumedCapacity | Data-plane actions | ReturnConsumedCapacity, NONE by default |
dynamodb:EnclosingOperation | Item actions inside TransactWriteItems / TransactGetItems; PartiQL* inside ExecuteTransaction | The enclosing operation's name |
dynamodb:FullTableScan | PartiQLSelect | true when the statement has no partition-key equality |
New services plug in by implementing iam_condition_keys_for on their AwsService impl; the dispatcher merges the result into the shared context before the evaluator runs.
Safe-fail semantics. A parse failure (malformed date, invalid CIDR, non-numeric value where numeric expected) is logged to fakecloud::iam::audit at debug level and evaluates to false. A key absent from the request follows AWS: positive operators are false, negated ones true (see above). An unrecognized operator fails closed in both directions: an Allow it guards does not apply, a Deny it guards does. The evaluator never lets a condition it cannot read grant access, nor lift a restriction. If you see unexpected denies, raise the fakecloud::iam::audit log level to see which statements fired or were skipped.
Resource-based policies
S3 bucket policies, SNS topic policies, Lambda function policies, KMS key policies, and DynamoDB table and stream policies are fully wired into the evaluator. When enforcement is on and a resource has a policy attached, dispatch fetches it and hands it to the evaluator alongside the caller's identity policies; the evaluator combines the two using AWS's cross-account semantics:
- Explicit Deny from either the identity policy or the resource policy wins immediately.
- Same-account callers (principal account ID equals the resource's owning account): the request is allowed if the identity policy or the resource policy grants it.
- Cross-account callers: the request is allowed only if the identity policy and the resource policy both grant it.
The resource's owning account is parsed from the ARN. S3 ARNs have an empty account segment, so for S3 the owner is the account that holds the bucket (bucket names are global); the decision is per resource, not a global config knob.
Where policies come from.
- S3 bucket policies are stored by
PutBucketPolicyand updated byDeleteBucketPolicy.GetBucketPolicyreturns the raw JSON. - SNS topic policies are stored in the topic's
Policyattribute bySetTopicAttributes(full document) or byAddPermission/RemovePermission(incremental statements).GetTopicAttributesreturns them. - Lambda function policies are built incrementally by
AddPermission: fakecloud composes a canonical{"Version":"2012-10-17","Statement":[...]}document from(StatementId, Action, Principal, SourceArn?, SourceAccount?)so the existing evaluator reads it without a Lambda-specific fork.SourceArnbecomes anArnLikeConditiononaws:SourceArn, andSourceAccountbecomes aStringEqualsConditiononaws:SourceAccount— both are already in the operator set.GetPolicyreturns the composed document;RemovePermissionstrips the matchingSidand leaves an emptyStatementarray behind, matching AWS. - DynamoDB table and stream policies are attached by
PutResourcePolicy(orCreateTable'sResourcePolicy), read byGetResourcePolicyand removed byDeleteResourcePolicy, honoringExpectedRevisionId(includingNO_POLICY). A table's policy also governs its indexes; a stream's policy is its own and belongs to that stream ARN. A principal in another account uses a table or stream by its ARN, for the data-plane,DescribeTable/UpdateTable/DeleteTable, tagging and stream-read operations AWS allows cross-account; the resource policy must grant it alongside the caller's identity policy. - MFA on
AssumeRole/GetSessionToken. ASerialNumber+TokenCodepair is verified, not taken on trust: the serial must name an MFA device of the caller (an IAM user's own assigned device; any device of the account for the root identity, including the reservedtest*one; none for role sessions) and the code must be the device's current RFC 6238 TOTP (one 30-second step of drift either way), computed from theBase32StringSeedCreateVirtualMFADevicereturned. Otherwise STS answersAccessDenied(MultiFactorAuthentication failed with invalid MFA one time pass code., or... unable to validate MFA code ...for an unknown or foreign serial) and no session is minted. Only a verified pair setsaws:MultiFactorAuthPresent/aws:MultiFactorAuthAge. A device registered by serial alone throughEnableMFADevice(a hardware token) has no seed fakecloud knows, so any 6-digit code is accepted for it. - IAM role trust policies (
assume_role_policy_document) are evaluated on everyAssumeRole,AssumeRoleWithSAML, andAssumeRoleWithWebIdentitycall before STS issues credentials. The trust policy is the only authorization source for role assumption — identity policies do not factor in. Caller principal, action (sts:AssumeRole*),Conditionkeys (sts:ExternalId,sts:RoleSessionName,sts:SourceIdentity,aws:MultiFactorAuthPresent,aws:SourceAccount), and federation-specific keys (saml:aud,saml:iss,<provider>:aud,<provider>:sub) are all populated.AssumeRoleWithWebIdentityadditionally requires the JWT'sissto match a registeredOpenIDConnectProviderandaudto be in itsclient_id_list; service-linked roles (path/aws-service-role/<service>/...) refuse non-service callers regardless of trust-policy contents.RoleArnis matched on the role's full path:arn:aws:iam::<account>:role/<name>does not name a role created under/team/, soAssumeRoleon it is denied.
Principal matching. Resource policies use Principal / NotPrincipal keys that identity policies don't. The evaluator supports the shapes resource policies actually use in practice:
| Shape | Meaning |
|---|---|
"Principal": "*" | Any authenticated principal |
"Principal": {"AWS": "*"} | Same as above |
"Principal": {"AWS": "arn:aws:iam::ACCOUNT:root"} | Any principal whose account ID is ACCOUNT |
"Principal": {"AWS": "ACCOUNT"} | 12-digit account ID shorthand, equivalent to ...:root |
"Principal": {"AWS": "arn:aws:iam::ACCOUNT:user/alice"} | Exact principal ARN match |
"Principal": {"AWS": [...]} | List form — any entry matches |
"Principal": {"Service": "events.amazonaws.com"} | Matches the service principal itself when fakecloud makes a request as that service (CloudFront fetching an S3 origin through an origin access control, see below), and an assumed-role session of a service-linked role for that service (path aws-service-role/<service>/) |
"Principal": {"Federated": "<provider>"} | Matches a federated session minted by AssumeRoleWithSAML / AssumeRoleWithWebIdentity whose provider ARN equals <provider>. Used by IAM role trust policies. |
"Principal": {"CanonicalUser": "<id>"} | Matches the principal with that S3 canonical user id. The principals fakecloud assigns one to are CloudFront origin access identities (their S3CanonicalUserId). |
NotPrincipal is fully evaluated: a statement with NotPrincipal applies to all callers except those matching any entry in the list (the exact inverse of Principal). The classic AWS pattern Deny + NotPrincipal ("deny everyone except this user") works correctly. NotPrincipal entries with unrecognized principal types are dropped from the match list; if all entries are unrecognized the statement is skipped with a fakecloud::iam::audit debug log and never silently grants.
Principal types other than AWS / Service / Federated / CanonicalUser fall through to "doesn't match" for the same reason.
Requests made as an AWS service. When fakecloud itself makes a request on a customer's behalf, it is authorized as the AWS-owned principal AWS would use. Today that is the CloudFront data plane fetching a private S3 origin: through an origin access control it acts as the cloudfront.amazonaws.com service principal, through a legacy origin access identity as arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity <id> (canonical user = the OAI's S3CanonicalUserId). Such a principal has no account and no identity policies, so only the bucket policy (or a public-read ACL) can grant it the action, and an account-root grant (arn:aws:iam::ACCOUNT:root) does not cover it. A service-principal request carries aws:SourceArn (the distribution ARN), aws:SourceAccount (the distribution's account), aws:PrincipalServiceName and aws:PrincipalIsAWSService: true, so the confused-deputy condition CDK and the AWS console write (StringEquals AWS:SourceArn) matches only that distribution; aws:SourceIp is absent, as in AWS. The identity is attached in-process and cannot be claimed by a client.
Condition blocks on resource-policy statements are evaluated with the same operator set and global condition keys as identity policies — the condition entry points are shared.
Not implemented
Ongoing coverage work:
- Service-specific condition keys for services / operations beyond the ones listed in the table above. The hook is
AwsService::iam_condition_keys_for; extending coverage is additive and requires no signature changes.
Permission boundaries
A managed policy attached to a user or role that caps the maximum permissions that identity can ever be granted. The effective permissions of a user with a boundary are the intersection of identity policies and the boundary: both must allow, and an explicit Deny in either layer wins.
- Attached via
PutUserPermissionsBoundary/PutRolePermissionsBoundary, removed viaDeleteUserPermissionsBoundary/DeleteRolePermissionsBoundary. - Dangling boundary ARN (the managed policy was deleted while still attached): the principal can perform no action until the boundary is removed or re-created — matches AWS behavior. Logged to
fakecloud::iam::auditat debug level. - Bypass rules: the account root and service-linked roles (role name starts with
AWSServiceRoleFor) are exempt from boundary evaluation. Withinevaluate_with_resource_policy, the boundary gates only the identity side — same-account resource-policy grants stand on their own.
Session policies
Inline policies passed to AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithSAML, or GetFederationToken via the Policy parameter (and PolicyArns) that further restrict the resulting temporary credentials below the role's own policies.
- Session policies are persisted on the STS temporary credential and evaluated as a third intersection layer: effective permission = identity ∩ boundary ∩ session. Each layer is evaluated independently; an explicit Deny in any layer wins.
- An STS call with no
Policy/PolicyArnsproduces a credential with no session-policy gate (pass-through), preserving Phase 2 behavior. GetSessionTokendoes not accept aPolicyparameter per AWS docs, so session policies do not apply to credentials minted by that operation.
Phase 4 — ABAC (tag-based conditions).
Tag-based access control via four condition key families:
| Condition key | Description | Enforced services |
|---|---|---|
aws:ResourceTag/<key> | Tags on the target resource | S3, SQS, SNS, IAM, KMS, DynamoDB (a table's tags, also for its indexes and streams) |
aws:RequestTag/<key> | Tags sent in the request (e.g. on CreateQueue, PutObject, CreateBucket) | S3 (including a CreateBucket tag set, which also requires s3:TagResource), SQS, SNS, IAM, KMS, DynamoDB (CreateTable, TagResource) |
aws:TagKeys | List of tag keys in the request (for ForAllValues/ForAnyValue) | S3, SQS, SNS, IAM, KMS, DynamoDB (CreateTable, TagResource, UntagResource) |
aws:PrincipalTag/<key> | Tags on the calling IAM user or assumed role | All enforced services |
Key semantics:
- The condition key prefix (
aws:ResourceTag/) is matched case-insensitively per AWS. The tag key part after the slash (Environment) is matched case-sensitively —aws:ResourceTag/Environmentandaws:ResourceTag/environmentreference different tags. aws:PrincipalTag/<key>is populated from the IAM user's or assumed role's tags at credential resolution time.- Services that don't implement ABAC yet (Lambda, Step Functions, etc.) gracefully skip tag evaluation with a
fakecloud::iam::auditdebug log. No fake tag values are ever returned. - Adding ABAC support to a new service requires implementing two trait methods:
resource_tags_for()andrequest_tags_from().
Phase 6 — Service Control Policies (SCPs)
fakecloud enforces SCPs as the top-of-chain allow-list ceiling above permission boundaries, session policies, and identity policies. SCPs only apply when an AWS Organizations organization exists in the process (see the Organizations reference) — without one, nothing changes.
The evaluation chain, outermost to innermost:
SCP ceiling -> permission boundary -> session policy -> identity policy (union resource policy)Each layer is an intersection gate with AWS's standard semantics: if the layer is present it must evaluate to Allow for the request to survive. An explicit Deny in any layer wins immediately.
SCP-specific rules:
- Inherited intersection. When a member account lives under
root -> OU_A -> OU_B -> account, every SCP attached along that path must allow the action. AWS intersects across ancestors; fakecloud mirrors it. - Management-account exemption. The account that called
CreateOrganizationis never constrained by SCPs, matching AWS. - Service-linked role exemption. Assumed roles whose name begins with
AWSServiceRoleForbypass SCP evaluation, same as permission boundaries. - Empty chain denies. If
FullAWSAccessis detached from the root and no other SCP grants the action up the path, the principal is denied. This matches AWS's allow-list ceiling semantics — SCPs are not deny lists. - Resource-policy branch untouched. SCPs gate the identity side only. A resource policy in the resource's account has its own authority; the caller's SCPs have no reach there.
Audit logs emit on fakecloud::iam::audit when an SCP layer produces an ExplicitDeny or caps the intersection to ImplicitDeny, so tests can inspect the decision path.
Practical example
Bootstrap a user with root credentials, attach a resource-scoped policy, then hit the service with their own access key:
# Start fakecloud with enforcement on.
FAKECLOUD_VERIFY_SIGV4=true FAKECLOUD_IAM=strict ./fakecloud
# Root-bypass bootstrap.
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \
aws --endpoint-url http://localhost:4566 iam create-user --user-name alice
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \
aws --endpoint-url http://localhost:4566 iam create-access-key --user-name alice
# -> emits AKIA..., SECRET...
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \
aws --endpoint-url http://localhost:4566 iam put-user-policy \
--user-name alice \
--policy-name ReadSelf \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::123456789012:user/alice"}]}'
# Alice can read herself...
AWS_ACCESS_KEY_ID=<alice-akid> AWS_SECRET_ACCESS_KEY=<alice-secret> \
aws --endpoint-url http://localhost:4566 iam get-user --user-name alice
# -> success
# ...but not anyone else.
AWS_ACCESS_KEY_ID=<alice-akid> AWS_SECRET_ACCESS_KEY=<alice-secret> \
aws --endpoint-url http://localhost:4566 iam get-user --user-name root
# -> AccessDeniedExceptionImage supply-chain (cosign + Trivy)
Every published container image — ghcr.io/faiscadev/fakecloud and the prebuilt RDS support images (fakecloud-postgres, fakecloud-mysql, fakecloud-mariadb) — is:
- Scanned by Trivy for
CRITICAL/HIGHOS and library vulnerabilities (ignore-unfixed: true). The release fails closed if any are found. - Signed with cosign keyless mode using the GitHub Actions OIDC token, so attestations are anchored to the workflow that built the image — no key management, no detached secret.
To verify an image before pulling:
cosign verify ghcr.io/faiscadev/fakecloud-postgres:16-0.13.1 \
--certificate-identity-regexp '^https://github\.com/faiscadev/fakecloud/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comSame shape works for fakecloud-mysql, fakecloud-mariadb, and the main fakecloud image. A successful verification means the image was built by a workflow run in the faiscadev/fakecloud repository — not republished by anyone else.
See also
- Limitations — what fakecloud doesn't do at all
- Configuration — full flag + env var reference
- IAM service docs — per-action coverage