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|strict

They'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:

  1. 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 from X-Amz-Content-Sha256 when present, otherwise sha256(body)).
  2. Signing key derived via the four-step HMAC chain AWS4 -> date -> region -> service -> aws4_request.
  3. Constant-time comparison against the signature the client sent.
  4. Clock skew window of ±15 minutes, matching AWS.

Verification failures return protocol-correct AWS errors before business logic runs:

FailureAWS error
Wrong signatureSignatureDoesNotMatch
Unknown or deactivated access keyInvalidClientTokenId
Expired STS temporary credentialsExpiredToken
Clock skew > 15 minRequestTimeTooSkewed
Malformed auth headerIncompleteSignature

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 the fakecloud::iam::audit tracing 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-correct AccessDeniedException before 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:

ServiceOperations and role parametersiam:PassedToService
LambdaCreateFunction / UpdateFunctionConfiguration Role; CreateCapacityProvider / UpdateCapacityProvider PermissionsConfig.CapacityProviderOperatorRoleArnlambda.amazonaws.com
EventBridge SchedulerCreateSchedule / UpdateSchedule Target.RoleArnscheduler.amazonaws.com
S3PutBucketReplication Roles3.amazonaws.com
SNSCreateTopic / SetTopicAttributes delivery-status roles (<Protocol>SuccessFeedbackRoleArn, <Protocol>FailureFeedbackRoleArn); Subscribe / SetSubscriptionAttributes SubscriptionRoleArnsns.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:

ServiceCovered actionsResource ARN shape
IAMAll 128 supported actionsarn:aws:iam::<account>:{user,role,group,policy,instance-profile,mfa,server-certificate,saml-provider,oidc-provider}/<name>
STSAll 8 supported actions* (or RoleArn for AssumeRole*)
SQSAll 20 supported actionsarn:aws:sqs:<region>:<account>:<queue-name>
SNSAll 34 supported actionsTopic / subscription / platform-app / endpoint ARNs
S3All 74 supported actionsarn:aws:s3:::<bucket>[/<key>] (object actions include the key; bucket actions don't)
KMSAll 47 supported actionsarn:aws:kms:<region>:<account>:key/<key-id> (key-targeted actions) or * (account-level actions like CreateKey, ListKeys)
DynamoDBAll 58 supported operationsarn: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 StreamsAll 4 supported operations (dynamodb: prefix)arn:aws:dynamodb:<region>:<account>:table/<name>/stream/<label>, or * for ListStreams
LambdaAll 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 SchedulerAll 12 supported actionsarn: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 / NotAction with * and ? wildcards. Service prefix match is case-insensitive; action names are case-sensitive (matches AWS).
  • Resource / NotResource with * 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 of Resource / NotResource ARNs and in String* / 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 other Version read ${...} literally, as AWS does.
  • Condition blocks: all 28 operators AWS defines, plus the ...IfExists suffix and the ForAllValues: / 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:

CategoryOperators
StringStringEquals, StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringLike, StringNotLike
NumericNumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEquals
DateDateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEquals (RFC3339 and epoch seconds both accepted)
BooleanBool
BinaryBinaryEquals
IP addressIpAddress, NotIpAddress (v4 and v6 CIDR, plus bare addresses)
ARNArnEquals, ArnNotEquals, ArnLike, ArnNotLike
ExistenceNull

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:

KeySource
aws:usernameLast segment of the IAM user ARN; unset for assumed-role / federated principals, matching AWS
aws:useridPrincipal.user_id (e.g. AIDA..., AROA...:<session>)
aws:PrincipalArnFull principal ARN
aws:PrincipalAccount12-digit account ID sourced from the credential (#381 alignment), not global config
aws:PrincipalTypePascalCase label: User / AssumedRole / FederatedUser / Account
aws:SourceIpRemote address of the incoming HTTP connection
aws:CurrentTimeServer-side evaluation timestamp (UTC)
aws:EpochTimeSame moment as aws:CurrentTime, in seconds since the Unix epoch
aws:SecureTransporttrue 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:RequestedRegionRegion extracted from SigV4 / config
aws:ResourceAccountAccount that owns the target resource (an S3 bucket's owner, the account in any other ARN)
aws:PrincipalOrgID / aws:PrincipalOrgPathsThe 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:SourceAccountSet on requests fakecloud makes as an AWS service principal (see below)

Supported service-specific condition keys:

KeyPopulated onSource
s3:prefixs3:ListObjects, s3:ListObjectsV2?prefix= query param
s3:delimiters3:ListObjects, s3:ListObjectsV2?delimiter= query param
s3:max-keyss3:ListObjects, s3:ListObjectsV2?max-keys= query param
s3:x-amz-aclany 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-controlany request carrying the headermatching x-amz-grant-* header
s3:x-amz-object-ownershipany request carrying the header, which in practice is CreateBucketx-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-sourceany request carrying the header (PutObject, CopyObject, CreateMultipartUpload)matching request header, so guardrails like DenyIncorrectEncryptionHeader admit compliant writes
sns:Protocolsns:SubscribeProtocol request parameter
sns:Endpointsns:SubscribeEndpoint request parameter
lambda:FunctionArnlambda:AddPermissionTarget function ARN resolved from the path
lambda:Principallambda:AddPermissionPrincipal field from the JSON body
sqs:MessageAttribute.<Name>sqs:SendMessageEach named MessageAttribute's StringValue (Binary / Number attributes fall back to the data type)
dynamodb:LeadingKeys / dynamodb:FirstPartitionKeyValuesGetItem, 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:AttributesItem 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:SelectGetItem, Query, Scan, BatchGetItem, PartiQLSelectSelect, else SPECIFIC_ATTRIBUTES with a projection, ALL_PROJECTED_ATTRIBUTES for an index query, ALL_ATTRIBUTES otherwise
dynamodb:ReturnValuesPutItem, UpdateItem, DeleteItemReturnValues, NONE by default
dynamodb:ReturnConsumedCapacityData-plane actionsReturnConsumedCapacity, NONE by default
dynamodb:EnclosingOperationItem actions inside TransactWriteItems / TransactGetItems; PartiQL* inside ExecuteTransactionThe enclosing operation's name
dynamodb:FullTableScanPartiQLSelecttrue 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 PutBucketPolicy and updated by DeleteBucketPolicy. GetBucketPolicy returns the raw JSON.
  • SNS topic policies are stored in the topic's Policy attribute by SetTopicAttributes (full document) or by AddPermission / RemovePermission (incremental statements). GetTopicAttributes returns 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. SourceArn becomes an ArnLike Condition on aws:SourceArn, and SourceAccount becomes a StringEquals Condition on aws:SourceAccount — both are already in the operator set. GetPolicy returns the composed document; RemovePermission strips the matching Sid and leaves an empty Statement array behind, matching AWS.
  • DynamoDB table and stream policies are attached by PutResourcePolicy (or CreateTable's ResourcePolicy), read by GetResourcePolicy and removed by DeleteResourcePolicy, honoring ExpectedRevisionId (including NO_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. A SerialNumber + TokenCode pair 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 reserved test* 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 the Base32StringSeed CreateVirtualMFADevice returned. Otherwise STS answers AccessDenied (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 sets aws:MultiFactorAuthPresent / aws:MultiFactorAuthAge. A device registered by serial alone through EnableMFADevice (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 every AssumeRole, AssumeRoleWithSAML, and AssumeRoleWithWebIdentity call 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*), Condition keys (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. AssumeRoleWithWebIdentity additionally requires the JWT's iss to match a registered OpenIDConnectProvider and aud to be in its client_id_list; service-linked roles (path /aws-service-role/<service>/...) refuse non-service callers regardless of trust-policy contents. RoleArn is matched on the role's full path: arn:aws:iam::<account>:role/<name> does not name a role created under /team/, so AssumeRole on 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:

ShapeMeaning
"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 via DeleteUserPermissionsBoundary / 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::audit at debug level.
  • Bypass rules: the account root and service-linked roles (role name starts with AWSServiceRoleFor) are exempt from boundary evaluation. Within evaluate_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 / PolicyArns produces a credential with no session-policy gate (pass-through), preserving Phase 2 behavior.
  • GetSessionToken does not accept a Policy parameter 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 keyDescriptionEnforced services
aws:ResourceTag/<key>Tags on the target resourceS3, 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:TagKeysList 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 roleAll 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/Environment and aws:ResourceTag/environment reference 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::audit debug log. No fake tag values are ever returned.
  • Adding ABAC support to a new service requires implementing two trait methods: resource_tags_for() and request_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 CreateOrganization is never constrained by SCPs, matching AWS.
  • Service-linked role exemption. Assumed roles whose name begins with AWSServiceRoleFor bypass SCP evaluation, same as permission boundaries.
  • Empty chain denies. If FullAWSAccess is 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
# -> AccessDeniedException

Image 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:

  1. Scanned by Trivy for CRITICAL/HIGH OS and library vulnerabilities (ignore-unfixed: true). The release fails closed if any are found.
  2. 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.com

Same 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