Files
versitygw/tests/integration/iam_access_control.go
T
niksis02 4756b4d236 feat: add STS web identity federation, IAM policy Condition support, and access control enforcement
Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size.

Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions.

Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control.

Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth.

Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities.
2026-08-15 17:49:00 +04:00

2844 lines
127 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
// This file tests authorization (allow/deny) decisions for the standalone
// IAM/STS service: identity-based inline policies (user and role), role
// trust policies, and condition evaluation across both. It deliberately does
// not test policy-document validation, malformed input, or other API
// surface already covered by iam_put_user_policy.go/iam_create_role.go/etc.
//
// Session/session-policy scope: AssumeRoleWithWebIdentity is the only action
// that mints a session in this codebase, and a real successful call requires
// the server to fetch a real JWKS from the token's issuer and verify a real
// cryptographic signature. The SSRF guard in iamutil's OIDC fetch path
// (isDisallowedFetchTarget) unconditionally rejects loopback, private
// (RFC1918), and link-local addresses as fetch targets — so no JWKS server
// this test process stands up on the same machine can ever be reachable,
// and a real successful AssumeRoleWithWebIdentity is unreachable from this
// suite by design. Every test below that needs to observe a trust-policy
// "Allowed" decision instead uses the same technique the rest of this
// package's AssumeRoleWithWebIdentity tests already use (see
// IAMAssumeRoleWithWebIdentity_oaud_condition_matches in
// iam_assume_role_with_web_identity.go): point the provider at a loopback
// URL and observe that evaluation reaches the network-dependent signature
// step (InvalidIdentityTokenIDPCommunicationError) rather than being
// rejected earlier by trust evaluation itself (AccessDenied or the
// claims-stage InvalidIdentityToken). Reaching that step is only possible
// once Principal, Condition, and audience matching have all already
// succeeded, so it's a reliable, deterministic proxy for "Allowed" — but it
// means this suite cannot exercise anything that requires an actual minted
// session (session-policy intersection, a live session calling further IAM
// actions).
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/url"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/versity/versitygw/iamapi/iamerr"
)
// Every ARN the gateway issues is scoped to this single fixed account.
const testAccountID = "000000000000"
const (
actGetUser = "iam:GetUser"
actListUsers = "iam:ListUsers"
actListUserPolicies = "iam:ListUserPolicies"
actGetUserPolicy = "iam:GetUserPolicy"
actDeleteUserPolicy = "iam:DeleteUserPolicy"
actPutUserPolicy = "iam:PutUserPolicy"
actCreateUser = "iam:CreateUser"
actGetRole = "iam:GetRole"
actListRolePolicies = "iam:ListRolePolicies"
)
// defaultTestAudience is the OIDC ClientIDList/token-audience pair used by
// every trust-policy test below that isn't specifically exercising audience
// matching itself
var defaultTestAudience = []string{"client1"}
// IAMAccessControl_ImplicitDenyNoMatchingPolicy verifies a caller with no
// policies at all is denied by default (no Allow ever exists to grant
// anything).
func IAMAccessControl_ImplicitDenyNoMatchingPolicy(s *S3Conf) error {
testName := "IAMAccessControl_ImplicitDenyNoMatchingPolicy"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", nil)
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_AllowGrantsMatchingRequest verifies a single matching
// Allow statement grants the request, and that the response actually
// reflects the target resource (not just a nil error) — proving the call
// was genuinely authorized and executed, not accidentally short-circuited.
func IAMAccessControl_AllowGrantsMatchingRequest(s *S3Conf) error {
testName := "IAMAccessControl_AllowGrantsMatchingRequest"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"grant": policy})
if err != nil {
return err
}
defer cleanupCaller()
out, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if err := wantAllowed(caller.arn, actGetUser, targetArn, err); err != nil {
return err
}
if out == nil || out.User == nil || aws.ToString(out.User.UserName) != targetName {
return fmt.Errorf("expected GetUser to return user %q, got %#v", targetName, out)
}
return nil
})
}
// IAMAccessControl_NonMatchingStatementDoesNotGrant verifies a policy whose
// only statement covers a *different* action does not grant the tested
// action — a non-matching statement contributes nothing, it isn't a
// fallback Allow.
func IAMAccessControl_NonMatchingStatementDoesNotGrant(s *S3Conf) error {
testName := "IAMAccessControl_NonMatchingStatementDoesNotGrant"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actListRolePolicies, Resource: "*"})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"grant": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ExplicitDenyOverridesAllow verifies an explicit Deny
// always wins over a matching Allow, regardless of statement order or
// whether the Deny is in the same policy document or a separate one.
func IAMAccessControl_ExplicitDenyOverridesAllow(s *S3Conf) error {
testName := "IAMAccessControl_ExplicitDenyOverridesAllow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
allow := accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}
deny := accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn}
cases := []struct {
name string
policies map[string]string
}{
{"deny after allow, same document", map[string]string{"p": policyDoc(allow, deny)}},
{"deny before allow, same document", map[string]string{"p": policyDoc(deny, allow)}},
{"allow and deny in separate documents", map[string]string{"allow": policyDoc(allow), "deny": policyDoc(deny)}},
}
for _, tc := range cases {
if err := func() error {
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", tc.policies)
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_MultipleStatementsEvaluatedIndependently verifies two
// statements in one policy document, covering two different actions, are
// each evaluated on their own terms: both grant their own action, and
// neither grants the other's.
func IAMAccessControl_MultipleStatementsEvaluatedIndependently(s *S3Conf) error {
testName := "IAMAccessControl_MultipleStatementsEvaluatedIndependently"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(
accessStatement{Sid: "AllowGet", Effect: "Allow", Action: actGetUser, Resource: targetArn},
accessStatement{Sid: "AllowListPolicies", Effect: "Allow", Action: actListUserPolicies, Resource: targetArn},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actGetUser, targetArn, err) != nil {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actListUserPolicies, targetArn, err) != nil {
return wantAllowed(caller.arn, actListUserPolicies, targetArn, err)
}
// Neither statement covers DeleteUserPolicy.
_, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")})
return wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err)
})
}
// IAMAccessControl_MultipleInlinePoliciesCombinedAllow verifies two separate
// inline policies attached to the same user are combined: a statement in
// either one is enough to grant its action.
func IAMAccessControl_MultipleInlinePoliciesCombinedAllow(s *S3Conf) error {
testName := "IAMAccessControl_MultipleInlinePoliciesCombinedAllow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policies := map[string]string{
"policy-a": policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn}),
"policy-b": policyDoc(accessStatement{Effect: "Allow", Action: actListUserPolicies, Resource: targetArn}),
}
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", policies)
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
_, err = listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)})
return wantAllowed(caller.arn, actListUserPolicies, targetArn, err)
})
}
// IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins verifies a Deny in
// one inline policy overrides an Allow in a *different* inline policy on the
// same user — combination is not "most permissive wins", explicit Deny is
// global across every attached policy.
func IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins(s *S3Conf) error {
testName := "IAMAccessControl_MultipleInlinePoliciesExplicitDenyWins"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policies := map[string]string{
"allow-everything": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"}),
"deny-get-user": policyDoc(accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn}),
}
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", policies)
if err != nil {
return err
}
defer cleanupCaller()
// The broad Allow still grants an unrelated action...
if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); err != nil {
return wantAllowed(caller.arn, actListUserPolicies, targetArn, err)
}
// ...but the specific Deny still wins for the action it names.
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies verifies an
// Allow statement present in a policy but not covering the tested
// action/resource contributes nothing — the request is still implicitly
// denied, not accidentally granted just because *some* Allow exists
// somewhere in the document.
func IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies(s *S3Conf) error {
testName := "IAMAccessControl_EffectNonMatchingAllowStillImplicitlyDenies"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
otherName, _, cleanupOther, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupOther()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "arn:aws:iam::" + testAccountID + ":user/" + otherName})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow verifies
// a Deny statement that doesn't cover the tested action/resource simply
// doesn't apply — it does not somehow block an unrelated Allow elsewhere in
// the same policy.
func IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow(s *S3Conf) error {
testName := "IAMAccessControl_EffectNonMatchingDenyDoesNotBlockUnrelatedAllow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(
accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn},
accessStatement{Effect: "Deny", Action: actDeleteUserPolicy, Resource: targetArn},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantAllowed(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ActionMatchingVariants covers exact, wildcard, array, and
// case-insensitive Action matching, all against the same target resource so
// only the Action dimension varies row to row.
func IAMAccessControl_ActionMatchingVariants(s *S3Conf) error {
testName := "IAMAccessControl_ActionMatchingVariants"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
cases := []struct {
name string
action any
wantAllowed bool
}{
{"exact action match", "iam:GetUser", true},
{"service wildcard iam:*", "iam:*", true},
{"operation prefix wildcard iam:Get*", "iam:Get*", true},
{"suffix wildcard iam:*User", "iam:*User", true},
{"single-char ? wildcard", "iam:GetUse?", true},
{"action present in an array", []string{"iam:ListUsers", "iam:GetUser"}, true},
{"case-insensitive policy action", "IAM:GETUSER", true},
{"nonmatching action", "iam:PutUserPolicy", false},
{"nonmatching prefix wildcard", "iam:List*", false},
}
for _, tc := range cases {
if err := func() error {
policy := policyDoc(accessStatement{Effect: "Allow", Action: tc.action, Resource: targetArn})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if tc.wantAllowed {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
return wantDenied(caller.arn, actGetUser, targetArn, err)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_ActionAllowOneDenyAnotherByOmission verifies a policy
// granting exactly one action grants only that action — a sibling action
// against the very same resource is still denied.
func IAMAccessControl_ActionAllowOneDenyAnotherByOmission(s *S3Conf) error {
testName := "IAMAccessControl_ActionAllowOneDenyAnotherByOmission"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
_, err = listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actListUserPolicies, targetArn, err)
})
}
// IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow verifies an
// explicit Deny for one specific action carves it out of an otherwise
// all-encompassing wildcard Allow, without affecting any other action the
// wildcard still covers.
func IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow(s *S3Conf) error {
testName := "IAMAccessControl_ActionExplicitDenySubsetOfWildcardAllow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(
accessStatement{Effect: "Allow", Action: "iam:*", Resource: targetArn},
accessStatement{Effect: "Deny", Action: actDeleteUserPolicy, Resource: targetArn},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
_, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")})
return wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err)
})
}
// IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded verifies an
// Allow+NotAction statement grants every action *except* the ones listed —
// the excluded action is denied, a nonexcluded one is allowed.
func IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded(s *S3Conf) error {
testName := "IAMAccessControl_NotActionAllowGrantsEverythingExceptExcluded"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(accessStatement{Effect: "Allow", NotAction: []string{actListUsers, actDeleteUserPolicy}, Resource: "*"})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
// GetUser is not in the NotAction list, so it's covered by the Allow.
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
// ListUsers is excluded via NotAction, so the statement doesn't cover it.
_, err = listIAMUsers(caller.client, &iam.ListUsersInput{})
return wantDenied(caller.arn, actListUsers, "*", err)
})
}
// IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded verifies the
// interaction between an Action-based Allow and a NotAction-based Deny: a
// broad Allow grants everything, but a Deny+NotAction statement denies every
// action *except* the one named — net effect, only that one action remains
// allowed.
func IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded(s *S3Conf) error {
testName := "IAMAccessControl_NotActionDenyBlocksEverythingExceptExcluded"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(
accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"},
accessStatement{Effect: "Deny", NotAction: actGetUser, Resource: "*"},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
// GetUser is excluded from the Deny's NotAction coverage, so only the
// Allow applies to it.
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
// Every other action is covered by the Deny (it's not GetUser).
_, err = listIAMUsers(caller.client, &iam.ListUsersInput{})
return wantDenied(caller.arn, actListUsers, "*", err)
})
}
// IAMAccessControl_ResourceMatchingVariants covers exact, wildcard, and
// array Resource matching for both a user and a role target.
func IAMAccessControl_ResourceMatchingVariants(s *S3Conf) error {
testName := "IAMAccessControl_ResourceMatchingVariants"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetUserName, targetUserArn, cleanupUser, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupUser()
targetRoleName, targetRoleArn, cleanupRole, err := newTargetRole(root)
if err != nil {
return err
}
defer cleanupRole()
pathUserName, pathUserArn, cleanupPathUser, err := newTargetUserWithPath(root, "/ac-team/")
if err != nil {
return err
}
defer cleanupPathUser()
otherName, _, cleanupOther, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupOther()
run := func(name, action, resourcePattern, wantResource string, call func(client *iam.Client) error) error {
policy := policyDoc(accessStatement{Effect: "Allow", Action: action, Resource: resourcePattern})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
defer cleanupCaller()
if err := wantAllowed(caller.arn, action, wantResource, call(caller.client)); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
if err := run("exact user ARN", actGetUser, targetUserArn, targetUserArn, func(c *iam.Client) error {
_, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(targetUserName)})
return err
}); err != nil {
return err
}
if err := run("exact role ARN", actGetRole, targetRoleArn, targetRoleArn, func(c *iam.Client) error {
_, err := getIAMRole(c, targetRoleName)
return err
}); err != nil {
return err
}
if err := run("wildcard resource ARN", actGetUser, "*", targetUserArn, func(c *iam.Client) error {
_, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(targetUserName)})
return err
}); err != nil {
return err
}
if err := run("resource path wildcard", actGetUser, "arn:aws:iam::"+testAccountID+":user/ac-team/*", pathUserArn, func(c *iam.Client) error {
_, err := getIAMUser(c, &iam.GetUserInput{UserName: aws.String(pathUserName)})
return err
}); err != nil {
return err
}
// Multiple resources in an array: both named ARNs are granted, a third
// (equally valid) resource is not.
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: []string{targetUserArn, pathUserArn}})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return fmt.Errorf("resource array: %w", err)
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetUserName)}); wantAllowed(caller.arn, actGetUser, targetUserArn, err) != nil {
return fmt.Errorf("resource array, first entry: %w", wantAllowed(caller.arn, actGetUser, targetUserArn, err))
}
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(pathUserName)}); wantAllowed(caller.arn, actGetUser, pathUserArn, err) != nil {
return fmt.Errorf("resource array, second entry: %w", wantAllowed(caller.arn, actGetUser, pathUserArn, err))
}
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); wantDenied(caller.arn, actGetUser, "(not in array)", err) != nil {
return fmt.Errorf("resource array, nonmatching entry: %w", wantDenied(caller.arn, actGetUser, "(not in array)", err))
}
// Nonmatching resource: exact grant to one user does not cover another.
exactPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetUserArn})
exactCaller, cleanupExact, err := newAccessControlCaller(root, s, "", map[string]string{"p": exactPolicy})
if err != nil {
return fmt.Errorf("nonmatching resource denied: %w", err)
}
defer cleanupExact()
_, err = getIAMUser(exactCaller.client, &iam.GetUserInput{UserName: aws.String(otherName)})
if err := wantDenied(exactCaller.arn, actGetUser, targetUserArn, err); err != nil {
return fmt.Errorf("nonmatching resource denied: %w", err)
}
return nil
})
}
// IAMAccessControl_ResourceOneAllowedOneDeniedSameAction verifies a
// resource-scoped Allow grants the same action against its named resource
// but denies it against an equally-valid, unrelated resource.
func IAMAccessControl_ResourceOneAllowedOneDeniedSameAction(s *S3Conf) error {
testName := "IAMAccessControl_ResourceOneAllowedOneDeniedSameAction"
return iamActionHandler(s, testName, func(root *iam.Client) error {
allowedName, allowedArn, cleanupAllowed, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupAllowed()
deniedName, deniedArn, cleanupDenied, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupDenied()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: allowedArn})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(allowedName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, allowedArn, err)
}
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(deniedName)})
return wantDenied(caller.arn, actGetUser, deniedArn, err)
})
}
// IAMAccessControl_ResourceWildcardRequiredForListAction verifies a
// List-type action (whose only valid resource-level scope is "*", per
// resourceForAction's classification) is denied by a resource-scoped grant
// naming a specific entity, and allowed once the grant uses "*".
func IAMAccessControl_ResourceWildcardRequiredForListAction(s *S3Conf) error {
testName := "IAMAccessControl_ResourceWildcardRequiredForListAction"
return iamActionHandler(s, testName, func(root *iam.Client) error {
_, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
scoped := policyDoc(accessStatement{Effect: "Allow", Action: actListUsers, Resource: targetArn})
scopedCaller, cleanupScoped, err := newAccessControlCaller(root, s, "", map[string]string{"p": scoped})
if err != nil {
return err
}
defer cleanupScoped()
_, err = listIAMUsers(scopedCaller.client, &iam.ListUsersInput{})
if err := wantDenied(scopedCaller.arn, actListUsers, "*", err); err != nil {
return fmt.Errorf("resource-scoped grant: %w", err)
}
wildcard := policyDoc(accessStatement{Effect: "Allow", Action: actListUsers, Resource: "*"})
wildcardCaller, cleanupWildcard, err := newAccessControlCaller(root, s, "", map[string]string{"p": wildcard})
if err != nil {
return err
}
defer cleanupWildcard()
_, err = listIAMUsers(wildcardCaller.client, &iam.ListUsersInput{})
if err := wantAllowed(wildcardCaller.arn, actListUsers, "*", err); err != nil {
return fmt.Errorf("wildcard grant: %w", err)
}
return nil
})
}
// IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow verifies a
// Deny scoped to one specific resource carves it out of a broader
// Resource:"*" Allow, without affecting any other resource the Allow still
// covers.
func IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow(s *S3Conf) error {
testName := "IAMAccessControl_ResourceExplicitDenyOverridesBroaderAllow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
blockedName, blockedArn, cleanupBlocked, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupBlocked()
otherName, otherArn, cleanupOther, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupOther()
policy := policyDoc(
accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*"},
accessStatement{Effect: "Deny", Action: actGetUser, Resource: blockedArn},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); err != nil {
return wantAllowed(caller.arn, actGetUser, otherArn, err)
}
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(blockedName)})
return wantDenied(caller.arn, actGetUser, blockedArn, err)
})
}
// IAMAccessControl_NotResourceExcludesTarget verifies both directions of
// NotResource: an Allow+NotResource statement applies to every resource
// *except* the excluded one, while a Deny+NotResource statement (layered
// over a broader baseline Allow) denies every resource *except* the
// excluded one — the excluded resource's fate inverts between the two.
func IAMAccessControl_NotResourceExcludesTarget(s *S3Conf) error {
testName := "IAMAccessControl_NotResourceExcludesTarget"
return iamActionHandler(s, testName, func(root *iam.Client) error {
user1Name, user1Arn, cleanup1, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanup1()
user2Name, user2Arn, cleanup2, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanup2()
// Allow + NotResource[user2]: user1 allowed, user2 (excluded) denied.
allowPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: user2Arn})
allowCaller, cleanupAllow, err := newAccessControlCaller(root, s, "", map[string]string{"p": allowPolicy})
if err != nil {
return err
}
defer cleanupAllow()
if _, err := getIAMUser(allowCaller.client, &iam.GetUserInput{UserName: aws.String(user1Name)}); wantAllowed(allowCaller.arn, actGetUser, user1Arn, err) != nil {
return fmt.Errorf("Allow+NotResource, non-excluded: %w", wantAllowed(allowCaller.arn, actGetUser, user1Arn, err))
}
if _, err := getIAMUser(allowCaller.client, &iam.GetUserInput{UserName: aws.String(user2Name)}); wantDenied(allowCaller.arn, actGetUser, user2Arn, err) != nil {
return fmt.Errorf("Allow+NotResource, excluded: %w", wantDenied(allowCaller.arn, actGetUser, user2Arn, err))
}
// Baseline Allow(*) + Deny+NotResource[user2]: user1 denied (Deny
// covers it, since it's not the excluded one), user2 allowed (Deny
// doesn't cover the excluded resource, so only the baseline Allow
// applies to it).
denyPolicy := policyDoc(
accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*"},
accessStatement{Effect: "Deny", Action: actGetUser, NotResource: user2Arn},
)
denyCaller, cleanupDeny, err := newAccessControlCaller(root, s, "", map[string]string{"p": denyPolicy})
if err != nil {
return err
}
defer cleanupDeny()
if _, err := getIAMUser(denyCaller.client, &iam.GetUserInput{UserName: aws.String(user1Name)}); wantDenied(denyCaller.arn, actGetUser, user1Arn, err) != nil {
return fmt.Errorf("Deny+NotResource, non-excluded: %w", wantDenied(denyCaller.arn, actGetUser, user1Arn, err))
}
if _, err := getIAMUser(denyCaller.client, &iam.GetUserInput{UserName: aws.String(user2Name)}); wantAllowed(denyCaller.arn, actGetUser, user2Arn, err) != nil {
return fmt.Errorf("Deny+NotResource, excluded: %w", wantAllowed(denyCaller.arn, actGetUser, user2Arn, err))
}
return nil
})
}
// IAMAccessControl_NotResourceMultipleExcludedResources verifies a
// NotResource array excludes every listed resource, not just the first.
func IAMAccessControl_NotResourceMultipleExcludedResources(s *S3Conf) error {
testName := "IAMAccessControl_NotResourceMultipleExcludedResources"
return iamActionHandler(s, testName, func(root *iam.Client) error {
includedName, includedArn, cleanupIncluded, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupIncluded()
excluded1Name, excluded1Arn, cleanupExcluded1, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupExcluded1()
excluded2Name, excluded2Arn, cleanupExcluded2, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupExcluded2()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: []string{excluded1Arn, excluded2Arn}})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(includedName)}); wantAllowed(caller.arn, actGetUser, includedArn, err) != nil {
return fmt.Errorf("non-excluded resource: %w", wantAllowed(caller.arn, actGetUser, includedArn, err))
}
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excluded1Name)}); wantDenied(caller.arn, actGetUser, excluded1Arn, err) != nil {
return fmt.Errorf("first excluded resource: %w", wantDenied(caller.arn, actGetUser, excluded1Arn, err))
}
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excluded2Name)})
if err := wantDenied(caller.arn, actGetUser, excluded2Arn, err); err != nil {
return fmt.Errorf("second excluded resource: %w", err)
}
return nil
})
}
// IAMAccessControl_NotResourceWildcardExclusion verifies NotResource
// supports the same wildcard glob Resource does: excluding a whole
// path-prefix pattern excludes every resource under it, not just one exact
// ARN.
func IAMAccessControl_NotResourceWildcardExclusion(s *S3Conf) error {
testName := "IAMAccessControl_NotResourceWildcardExclusion"
return iamActionHandler(s, testName, func(root *iam.Client) error {
excludedName, excludedArn, cleanupExcluded, err := newTargetUserWithPath(root, "/ac-excluded/")
if err != nil {
return err
}
defer cleanupExcluded()
includedName, includedArn, cleanupIncluded, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupIncluded()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, NotResource: "arn:aws:iam::" + testAccountID + ":user/ac-excluded/*"})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(includedName)}); wantAllowed(caller.arn, actGetUser, includedArn, err) != nil {
return fmt.Errorf("outside excluded path: %w", wantAllowed(caller.arn, actGetUser, includedArn, err))
}
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(excludedName)})
if err := wantDenied(caller.arn, actGetUser, excludedArn, err); err != nil {
return fmt.Errorf("inside excluded path: %w", err)
}
return nil
})
}
// IAMAccessControl_ConditionStringOperators covers the full String
// condition-operator family against aws:username — a key this suite fully
// controls on both sides (the caller's actual username, and the policy's
// expected value), giving every row a deterministic outcome.
func IAMAccessControl_ConditionStringOperators(s *S3Conf) error {
testName := "IAMAccessControl_ConditionStringOperators"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
cases := []struct {
name string
callerName string
condition func(callerName string) json.RawMessage
wantAllowed bool
}{
{"StringEquals exact match", "ac-str-alice-" + genRandString(6),
func(c string) json.RawMessage { return cond("StringEquals", "aws:username", c) }, true},
{"StringEquals nonmatch", "ac-str-bob-" + genRandString(6),
func(string) json.RawMessage { return cond("StringEquals", "aws:username", "someone-else") }, false},
{"StringNotEquals matches when different", "ac-str-carol-" + genRandString(6),
func(string) json.RawMessage { return cond("StringNotEquals", "aws:username", "someone-else") }, true},
{"StringNotEquals denies when equal", "ac-str-dave-" + genRandString(6),
func(c string) json.RawMessage { return cond("StringNotEquals", "aws:username", c) }, false},
{"StringEqualsIgnoreCase matches different case", "ac-str-erin-" + genRandString(6),
func(c string) json.RawMessage { return cond("StringEqualsIgnoreCase", "aws:username", upperASCII(c)) }, true},
{"StringNotEqualsIgnoreCase denies matching case-insensitively", "ac-str-frank-" + genRandString(6),
func(c string) json.RawMessage {
return cond("StringNotEqualsIgnoreCase", "aws:username", upperASCII(c))
}, false},
{"StringLike prefix wildcard", "ac-str-wild-prefix-" + genRandString(6),
func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wild-prefix-*") }, true},
{"StringLike suffix wildcard", "ac-str-wild-suffix-suf",
func(string) json.RawMessage { return cond("StringLike", "aws:username", "*-suf") }, true},
{"StringLike middle wildcard", "ac-str-wild-mid-zzz-tail",
func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wild-mid-*-tail") }, true},
{"StringLike ? wildcard", "ac-str-wld-abc",
func(string) json.RawMessage { return cond("StringLike", "aws:username", "ac-str-wld-a?c") }, true},
{"StringLike nonmatch", "ac-str-nomatch-" + genRandString(6),
func(string) json.RawMessage { return cond("StringLike", "aws:username", "totally-different-*") }, false},
{"StringNotLike denies matching wildcard", "ac-str-notlike-" + genRandString(6),
func(string) json.RawMessage { return cond("StringNotLike", "aws:username", "ac-str-notlike-*") }, false},
{"StringNotLike allows nonmatching wildcard", "ac-str-abc-" + genRandString(6),
func(string) json.RawMessage { return cond("StringNotLike", "aws:username", "zzz-*") }, true},
}
for _, tc := range cases {
if err := func() error {
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition(tc.callerName)})
caller, cleanupCaller, err := newAccessControlCaller(root, s, tc.callerName, map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if tc.wantAllowed {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
return wantDenied(caller.arn, actGetUser, targetArn, err)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_ConditionStringMultipleExpectedValuesOR verifies a
// StringEquals condition with an array of expected values matches if the
// actual value equals *any* of them.
func IAMAccessControl_ConditionStringMultipleExpectedValuesOR(s *S3Conf) error {
testName := "IAMAccessControl_ConditionStringMultipleExpectedValuesOR"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
callerName := "ac-str-or-" + genRandString(8)
condition := cond("StringEquals", "aws:username", []string{"nobody-1", callerName, "nobody-2"})
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition})
caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantAllowed(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ConditionArnOperators covers the ArnEquals/ArnLike/
// ArnNotEquals/ArnNotLike family against aws:PrincipalArn — a real,
// fully-known ARN this suite controls exactly (the caller's own Arn).
func IAMAccessControl_ConditionArnOperators(s *S3Conf) error {
testName := "IAMAccessControl_ConditionArnOperators"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
callerName := "ac-arn-" + genRandString(8)
callerArnPattern := "arn:aws:iam::" + testAccountID + ":user/" + callerName
otherArn := "arn:aws:iam::" + testAccountID + ":user/someone-else"
cases := []struct {
name string
condition json.RawMessage
wantAllowed bool
}{
{"ArnEquals exact match", cond("ArnEquals", "aws:PrincipalArn", callerArnPattern), true},
{"ArnEquals nonmatch", cond("ArnEquals", "aws:PrincipalArn", otherArn), false},
{"ArnLike wildcard match", cond("ArnLike", "aws:PrincipalArn", "arn:aws:iam::"+testAccountID+":user/ac-arn-*"), true},
{"ArnNotEquals matches when different", cond("ArnNotEquals", "aws:PrincipalArn", otherArn), true},
{"ArnNotEquals denies when equal", cond("ArnNotEquals", "aws:PrincipalArn", callerArnPattern), false},
{"ArnNotLike denies matching wildcard", cond("ArnNotLike", "aws:PrincipalArn", "arn:aws:iam::"+testAccountID+":user/ac-arn-*"), false},
{"array of expected ARNs matches any", cond("ArnEquals", "aws:PrincipalArn", []string{otherArn, callerArnPattern}), true},
}
for _, tc := range cases {
if err := func() error {
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition})
caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if tc.wantAllowed {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
return wantDenied(caller.arn, actGetUser, targetArn, err)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_ConditionIpAddressRealSourceIp covers IpAddress/
// NotIpAddress against the *real* aws:SourceIp the gateway observes for this
// test process's own connection (see callerSourceIP), proving the
// source-IP condition context is actually wired end to end — not just that
// the operator's CIDR logic works in isolation (see
// IAMAccessControl_ConditionIpAddressOperators for the broader operator
// coverage via a fully test-controlled claim value).
func IAMAccessControl_ConditionIpAddressRealSourceIp(s *S3Conf) error {
testName := "IAMAccessControl_ConditionIpAddressRealSourceIp"
return iamActionHandler(s, testName, func(root *iam.Client) error {
sourceIP, err := callerSourceIP(s)
if err != nil {
return err
}
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
cases := []struct {
name string
condition json.RawMessage
wantAllowed bool
}{
{"exact IP match", cond("IpAddress", "aws:SourceIp", sourceIP), true},
{"broad CIDR match", cond("IpAddress", "aws:SourceIp", "127.0.0.0/8"), true},
{"CIDR outside range denied", cond("IpAddress", "aws:SourceIp", "10.0.0.0/8"), false},
{"NotIpAddress denies matching range", cond("NotIpAddress", "aws:SourceIp", "127.0.0.0/8"), false},
{"NotIpAddress allows non-matching range", cond("NotIpAddress", "aws:SourceIp", "10.0.0.0/8"), true},
{"multiple CIDRs, one matches (OR)", cond("IpAddress", "aws:SourceIp", []string{"10.0.0.0/8", "127.0.0.0/8"}), true},
}
for _, tc := range cases {
if err := func() error {
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: tc.condition})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if tc.wantAllowed {
return wantAllowed(caller.arn, actGetUser, targetArn, err)
}
return wantDenied(caller.arn, actGetUser, targetArn, err)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow
// verifies a Deny scoped to one IP range carves it out of a broader Allow,
// using a range guaranteed to contain this test process's real source IP.
func IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow(s *S3Conf) error {
testName := "IAMAccessControl_ConditionIpAddressExplicitDenyOverridesBroaderAllow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
if _, err := callerSourceIP(s); err != nil {
return err
}
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
policy := policyDoc(
accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn},
accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("IpAddress", "aws:SourceIp", "127.0.0.0/8")},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ConditionMultipleContextKeysANDed verifies two different
// condition keys within the same Condition block are ANDed: both
// aws:username and aws:PrincipalTag/department must match for the statement
// to apply.
func IAMAccessControl_ConditionMultipleContextKeysANDed(s *S3Conf) error {
testName := "IAMAccessControl_ConditionMultipleContextKeysANDed"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
callerName := "ac-and-" + genRandString(8)
condition := condAll(map[string]map[string]any{
"StringEquals": {"aws:username": callerName, "aws:PrincipalTag/department": "eng"},
})
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition})
// Both keys match.
matching, cleanupMatching, err := newAccessControlCallerTagged(root, s, callerName, map[string]string{"p": policy}, map[string]string{"department": "eng"})
if err != nil {
return err
}
defer cleanupMatching()
if _, err := getIAMUser(matching.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantAllowed(matching.arn, actGetUser, targetArn, err) != nil {
return fmt.Errorf("both keys match: %w", wantAllowed(matching.arn, actGetUser, targetArn, err))
}
// Username matches but the tag does not: one failed key voids the
// whole statement (AND, not OR, across keys).
wrongTagName := "ac-and-" + genRandString(8)
wrongTagCondition := condAll(map[string]map[string]any{
"StringEquals": {"aws:username": wrongTagName, "aws:PrincipalTag/department": "eng"},
})
wrongTagPolicy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: wrongTagCondition})
mismatched, cleanupMismatched, err := newAccessControlCallerTagged(root, s, wrongTagName, map[string]string{"p": wrongTagPolicy}, map[string]string{"department": "sales"})
if err != nil {
return err
}
defer cleanupMismatched()
_, err = getIAMUser(mismatched.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if err := wantDenied(mismatched.arn, actGetUser, targetArn, err); err != nil {
return fmt.Errorf("one key mismatched: %w", err)
}
return nil
})
}
// IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply verifies
// that when an Allow's condition matches but a separate Deny statement's own
// condition does *not* match, the Deny simply doesn't apply and the Allow
// wins — a failing condition on a Deny is not the same as the Deny being
// absent, but it does mean that particular Deny never fires.
func IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply(s *S3Conf) error {
testName := "IAMAccessControl_ConditionAllowMatchesDenyConditionDoesNotApply"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
callerName := "ac-mixed-" + genRandString(8)
policy := policyDoc(
accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn},
accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", "not-"+callerName)},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantAllowed(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins verifies that when
// both an Allow's and a Deny's conditions match the same request, the Deny
// still wins — condition-matching does not change explicit Deny precedence.
func IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins(s *S3Conf) error {
testName := "IAMAccessControl_ConditionAllowAndDenyBothMatchDenyWins"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
callerName := "ac-bothmatch-" + genRandString(8)
policy := policyDoc(
accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", callerName)},
accessStatement{Effect: "Deny", Action: actGetUser, Resource: targetArn, Condition: cond("StringEquals", "aws:username", callerName)},
)
caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ConditionOneFailedConditionVoidsStatement verifies a
// statement combining two condition keys (ANDed) does not apply if either
// one fails to match — demonstrated here via aws:username (matching) AND
// aws:SourceIp (deliberately scoped to a range that excludes this test
// process's real source IP).
func IAMAccessControl_ConditionOneFailedConditionVoidsStatement(s *S3Conf) error {
testName := "IAMAccessControl_ConditionOneFailedConditionVoidsStatement"
return iamActionHandler(s, testName, func(root *iam.Client) error {
if _, err := callerSourceIP(s); err != nil {
return err
}
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
callerName := "ac-voided-" + genRandString(8)
condition := condAll(map[string]map[string]any{
"StringEquals": {"aws:username": callerName},
"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}, // deliberately excludes the real (127.0.0.0/8) source
})
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition})
caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantDenied(caller.arn, actGetUser, targetArn, err)
})
}
// IAMAccessControl_ConditionNullPrincipalTag covers the Null operator
// against aws:PrincipalTag/<key>, a key that's genuinely absent from
// request context for an untagged caller and present for a tagged one —
// exercising Null's "key does not exist"/"key exists" semantics against a
// real, request-driven context key rather than a synthetic one.
func IAMAccessControl_ConditionNullPrincipalTag(s *S3Conf) error {
testName := "IAMAccessControl_ConditionNullPrincipalTag"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
run := func(name string, tags map[string]string, nullValue string, wantAllow bool) error {
condition := cond("Null", "aws:PrincipalTag/department", nullValue)
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition})
caller, cleanupCaller, err := newAccessControlCallerTagged(root, s, "", map[string]string{"p": policy}, tags)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if wantAllow {
err = wantAllowed(caller.arn, actGetUser, targetArn, err)
} else {
err = wantDenied(caller.arn, actGetUser, targetArn, err)
}
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
if err := run("Null true matches absent tag", nil, "true", true); err != nil {
return err
}
if err := run("Null true denies present tag", map[string]string{"department": "eng"}, "true", false); err != nil {
return err
}
if err := run("Null false matches present tag", map[string]string{"department": "eng"}, "false", true); err != nil {
return err
}
return run("Null false denies absent tag", nil, "false", false)
})
}
// IAMAccessControl_ConditionIfExistsPrincipalTag covers a StringEqualsIfExists
// condition against aws:PrincipalTag/<key>: absent (vacuously allowed),
// present and matching (allowed), present and mismatched (denied).
func IAMAccessControl_ConditionIfExistsPrincipalTag(s *S3Conf) error {
testName := "IAMAccessControl_ConditionIfExistsPrincipalTag"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
condition := cond("StringEqualsIfExists", "aws:PrincipalTag/department", "eng")
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition})
run := func(name string, tags map[string]string, wantAllow bool) error {
caller, cleanupCaller, err := newAccessControlCallerTagged(root, s, "", map[string]string{"p": policy}, tags)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if wantAllow {
err = wantAllowed(caller.arn, actGetUser, targetArn, err)
} else {
err = wantDenied(caller.arn, actGetUser, targetArn, err)
}
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
if err := run("absent tag is vacuously allowed", nil, true); err != nil {
return err
}
if err := run("present matching tag allowed", map[string]string{"department": "eng"}, true); err != nil {
return err
}
return run("present mismatched tag denied", map[string]string{"department": "sales"}, false)
})
}
// IAMAccessControl_ConditionResourceTagOnTarget covers iam:ResourceTag/
// aws:ResourceTag: a Condition scoping the *target* resource's own tag,
// proving resourceForAction's tag resolution is wired into Condition
// evaluation, not just the caller's own tags.
func IAMAccessControl_ConditionResourceTagOnTarget(s *S3Conf) error {
testName := "IAMAccessControl_ConditionResourceTagOnTarget"
return iamActionHandler(s, testName, func(root *iam.Client) error {
taggedName, taggedArn, cleanupTagged, err := newTargetUserTagged(root, map[string]string{"team": "payments"})
if err != nil {
return err
}
defer cleanupTagged()
untaggedName, untaggedArn, cleanupUntagged, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupUntagged()
condition := cond("StringEquals", "iam:ResourceTag/team", "payments")
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "*", Condition: condition})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(taggedName)}); wantAllowed(caller.arn, actGetUser, taggedArn, err) != nil {
return fmt.Errorf("matching resource tag: %w", wantAllowed(caller.arn, actGetUser, taggedArn, err))
}
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(untaggedName)})
if err := wantDenied(caller.arn, actGetUser, untaggedArn, err); err != nil {
return fmt.Errorf("untagged resource: %w", err)
}
return nil
})
}
// IAMAccessControl_ConditionRequestTagOnCreateUser covers aws:RequestTag/
// aws:TagKeys: a Condition scoping the Tags parameter of a CreateUser
// request itself, proving request-scoped (not just principal- or
// resource-scoped) context is evaluated.
func IAMAccessControl_ConditionRequestTagOnCreateUser(s *S3Conf) error {
testName := "IAMAccessControl_ConditionRequestTagOnCreateUser"
return iamActionHandler(s, testName, func(root *iam.Client) error {
condition := cond("StringEquals", "aws:RequestTag/team", "payments")
policy := policyDoc(accessStatement{
Effect: "Allow", Action: actCreateUser,
Resource: "arn:aws:iam::" + testAccountID + ":user/ac-created-*",
Condition: condition,
})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
allowedName := "ac-created-" + genRandString(10)
out, err := createIAMUser(caller.client, &iam.CreateUserInput{
UserName: aws.String(allowedName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("payments")}},
})
if err := wantAllowed(caller.arn, actCreateUser, allowedName, err); err != nil {
return fmt.Errorf("matching request tag: %w", err)
}
if out != nil {
defer deleteIAMUser(root, allowedName)
}
deniedName := "ac-created-" + genRandString(10)
_, err = createIAMUser(caller.client, &iam.CreateUserInput{
UserName: aws.String(deniedName), Tags: []iamtypes.Tag{{Key: aws.String("team"), Value: aws.String("other")}},
})
return wantDenied(caller.arn, actCreateUser, deniedName, err)
})
}
// IAMAccessControl_ConditionCurrentTimeBroadWindow covers Numeric/Date
// operators against the server's own request-time keys (aws:EpochTime,
// aws:CurrentTime) — since "now" can't be injected or fixed by the test,
// this uses deliberately broad, never-flaky bounds (year 2001 through year
// 2100) rather than tight boundaries; see
// IAMAccessControl_ConditionNumericOperators/ConditionDateOperators for
// precise boundary coverage against a fully test-controlled claim value.
func IAMAccessControl_ConditionCurrentTimeBroadWindow(s *S3Conf) error {
testName := "IAMAccessControl_ConditionCurrentTimeBroadWindow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
condition := condAll(map[string]map[string]any{
"NumericGreaterThan": {"aws:EpochTime": "1000000000"}, // ~2001
"NumericLessThan": {"aws:EpochTime": "4102444800"}, // ~2100
"DateGreaterThan": {"aws:CurrentTime": "2001-01-01T00:00:00Z"},
"DateLessThan": {"aws:CurrentTime": "2100-01-01T00:00:00Z"},
})
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: targetArn, Condition: condition})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
return wantAllowed(caller.arn, actGetUser, targetArn, err)
})
}
// upperASCII uppercases a plain ASCII string (test fixture names are always
// ASCII), avoiding a dependency on strings.ToUpper's full-Unicode behavior
// for what's fundamentally a fixed test value.
func upperASCII(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'a' && c <= 'z' {
b[i] = c - ('a' - 'A')
}
}
return string(b)
}
// IAMAccessControl_ConditionNumericOperators covers the full Numeric
// condition-operator family, using a custom "level" claim this suite fully
// controls, around a fixed boundary value of 5.
func IAMAccessControl_ConditionNumericOperators(s *S3Conf) error {
testName := "IAMAccessControl_ConditionNumericOperators"
return iamActionHandler(s, testName, func(root *iam.Client) error {
numCond := func(operator string, value any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond(operator, host+":level", value) }
}
cases := []federatedConditionCase{
{"NumericEquals at boundary allowed", map[string]any{"level": 5}, numCond("NumericEquals", 5), true},
{"NumericEquals off boundary denied", map[string]any{"level": 5}, numCond("NumericEquals", 6), false},
{"NumericNotEquals allowed when different", map[string]any{"level": 5}, numCond("NumericNotEquals", 6), true},
{"NumericNotEquals denied when equal", map[string]any{"level": 5}, numCond("NumericNotEquals", 5), false},
{"NumericLessThan below boundary allowed", map[string]any{"level": 5}, numCond("NumericLessThan", 6), true},
{"NumericLessThan at boundary denied (exclusive)", map[string]any{"level": 5}, numCond("NumericLessThan", 5), false},
{"NumericLessThanEquals at boundary allowed (inclusive)", map[string]any{"level": 5}, numCond("NumericLessThanEquals", 5), true},
{"NumericLessThanEquals above boundary denied", map[string]any{"level": 6}, numCond("NumericLessThanEquals", 5), false},
{"NumericGreaterThan above boundary allowed", map[string]any{"level": 6}, numCond("NumericGreaterThan", 5), true},
{"NumericGreaterThan at boundary denied (exclusive)", map[string]any{"level": 5}, numCond("NumericGreaterThan", 5), false},
{"NumericGreaterThanEquals at boundary allowed (inclusive)", map[string]any{"level": 5}, numCond("NumericGreaterThanEquals", 5), true},
{"NumericGreaterThanEquals below boundary denied", map[string]any{"level": 4}, numCond("NumericGreaterThanEquals", 5), false},
{"multiple expected values matches any (OR)", map[string]any{"level": 5}, numCond("NumericEquals", []any{5, 100}), true},
{"missing context key denies", map[string]any{}, numCond("NumericEquals", 5), false},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionDateOperators covers the full Date
// condition-operator family, using a custom "joined" claim around a fixed
// boundary of 2024-06-15T00:00:00Z (epoch 1718409600) — both RFC3339 and
// epoch-seconds forms are exercised since evaluateCondition accepts either
// on either side.
func IAMAccessControl_ConditionDateOperators(s *S3Conf) error {
testName := "IAMAccessControl_ConditionDateOperators"
return iamActionHandler(s, testName, func(root *iam.Client) error {
const boundary = "2024-06-15T00:00:00Z"
const before = "2024-01-01T00:00:00Z"
const after = "2024-12-01T00:00:00Z"
dateCond := func(operator string, value any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond(operator, host+":joined", value) }
}
cases := []federatedConditionCase{
{"DateEquals exact match", map[string]any{"joined": boundary}, dateCond("DateEquals", boundary), true},
{"DateEquals nonmatch", map[string]any{"joined": boundary}, dateCond("DateEquals", before), false},
{"DateEquals matches across epoch-vs-RFC3339 forms", map[string]any{"joined": "1718409600"}, dateCond("DateEquals", boundary), true},
{"DateNotEquals allowed when different", map[string]any{"joined": boundary}, dateCond("DateNotEquals", before), true},
{"DateNotEquals denied when equal", map[string]any{"joined": boundary}, dateCond("DateNotEquals", boundary), false},
{"DateLessThan before boundary allowed", map[string]any{"joined": before}, dateCond("DateLessThan", boundary), true},
{"DateLessThan at boundary denied (exclusive)", map[string]any{"joined": boundary}, dateCond("DateLessThan", boundary), false},
{"DateLessThanEquals at boundary allowed (inclusive)", map[string]any{"joined": boundary}, dateCond("DateLessThanEquals", boundary), true},
{"DateLessThanEquals after boundary denied", map[string]any{"joined": after}, dateCond("DateLessThanEquals", boundary), false},
{"DateGreaterThan after boundary allowed", map[string]any{"joined": after}, dateCond("DateGreaterThan", boundary), true},
{"DateGreaterThan at boundary denied (exclusive)", map[string]any{"joined": boundary}, dateCond("DateGreaterThan", boundary), false},
{"DateGreaterThanEquals at boundary allowed (inclusive)", map[string]any{"joined": boundary}, dateCond("DateGreaterThanEquals", boundary), true},
{"DateGreaterThanEquals before boundary denied", map[string]any{"joined": before}, dateCond("DateGreaterThanEquals", boundary), false},
{"multiple expected dates matches any (OR)", map[string]any{"joined": boundary}, dateCond("DateEquals", []any{before, boundary}), true},
{"missing date context denies", map[string]any{}, dateCond("DateGreaterThan", boundary), false},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionBoolOperator covers Bool: true/false claim
// values, a string-typed "true"/"false" claim (still matched, since both
// sides parse via strconv.ParseBool), and a missing key.
func IAMAccessControl_ConditionBoolOperator(s *S3Conf) error {
testName := "IAMAccessControl_ConditionBoolOperator"
return iamActionHandler(s, testName, func(root *iam.Client) error {
boolCond := func(value any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond("Bool", host+":admin", value) }
}
cases := []federatedConditionCase{
{"true claim matches Bool true", map[string]any{"admin": true}, boolCond(true), true},
{"false claim denied against Bool true", map[string]any{"admin": false}, boolCond(true), false},
{"false claim matches Bool false", map[string]any{"admin": false}, boolCond(false), true},
{"string representation \"true\" matches Bool true", map[string]any{"admin": "true"}, boolCond(true), true},
{"missing key denies", map[string]any{}, boolCond(true), false},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionNullOperatorClaim covers Null against a custom
// claim: key exists vs. does not, Null:true vs. Null:false, and Null
// combined (ANDed) with a separate StringEquals condition in the same
// statement.
func IAMAccessControl_ConditionNullOperatorClaim(s *S3Conf) error {
testName := "IAMAccessControl_ConditionNullOperatorClaim"
return iamActionHandler(s, testName, func(root *iam.Client) error {
nullCond := func(value any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond("Null", host+":nickname", value) }
}
cases := []federatedConditionCase{
{"Null true matches when key absent", map[string]any{}, nullCond("true"), true},
{"Null true denies when key present", map[string]any{"nickname": "bob"}, nullCond("true"), false},
{"Null false matches when key present", map[string]any{"nickname": "bob"}, nullCond("false"), true},
{"Null false denies when key absent", map[string]any{}, nullCond("false"), false},
{
"Null combined with StringEquals: both satisfied allowed",
map[string]any{"nickname": "bob"},
func(host string) json.RawMessage {
return condAll(map[string]map[string]any{
"Null": {host + ":nickname": "false"},
"StringEquals": {host + ":nickname": "bob"},
})
},
true,
},
{
"Null combined with StringEquals: Null satisfied but StringEquals fails denies",
map[string]any{"nickname": "bob"},
func(host string) json.RawMessage {
return condAll(map[string]map[string]any{
"Null": {host + ":nickname": "false"},
"StringEquals": {host + ":nickname": "someone-else"},
})
},
false,
},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionBinaryEqualsOperator covers BinaryEquals with
// deterministic base64-encoded claim values.
func IAMAccessControl_ConditionBinaryEqualsOperator(s *S3Conf) error {
testName := "IAMAccessControl_ConditionBinaryEqualsOperator"
return iamActionHandler(s, testName, func(root *iam.Client) error {
const wantB64 = "aGVsbG8=" // base64("hello")
const otherB64 = "d29ybGQ=" // base64("world")
binCond := func(value any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond("BinaryEquals", host+":cert", value) }
}
cases := []federatedConditionCase{
{"matching base64 value allowed", map[string]any{"cert": wantB64}, binCond(wantB64), true},
{"nonmatching base64 value denied", map[string]any{"cert": otherB64}, binCond(wantB64), false},
{"missing key denied", map[string]any{}, binCond(wantB64), false},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionForAnyValueOperator covers ForAnyValue:
// StringEquals against a multi-valued "groups" claim: one request value
// matching is enough.
func IAMAccessControl_ConditionForAnyValueOperator(s *S3Conf) error {
testName := "IAMAccessControl_ConditionForAnyValueOperator"
return iamActionHandler(s, testName, func(root *iam.Client) error {
anyCond := func(expected any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond("ForAnyValue:StringEquals", host+":groups", expected) }
}
cases := []federatedConditionCase{
{"one request value matches", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"qa", "admin"}), true},
{"all request values match", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"dev", "qa"}), true},
{"none match", map[string]any{"groups": []string{"dev", "qa"}}, anyCond([]any{"admin"}), false},
{"empty request-value set never matches", map[string]any{"groups": []string{}}, anyCond([]any{"dev"}), false},
{"missing context key denies", map[string]any{}, anyCond([]any{"dev"}), false},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionForAllValuesOperator covers
// ForAllValues:StringEquals against a multi-valued "groups" claim: every
// request value must match one of the expected values.
func IAMAccessControl_ConditionForAllValuesOperator(s *S3Conf) error {
testName := "IAMAccessControl_ConditionForAllValuesOperator"
return iamActionHandler(s, testName, func(root *iam.Client) error {
allCond := func(expected any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond("ForAllValues:StringEquals", host+":groups", expected) }
}
cases := []federatedConditionCase{
{"all request values match", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"dev", "qa", "admin"}), true},
{"only some request values match denies", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"dev"}), false},
{"none match denies", map[string]any{"groups": []string{"dev", "qa"}}, allCond([]any{"admin"}), false},
{"empty request-value set is vacuously true", map[string]any{"groups": []string{}}, allCond([]any{"dev"}), true},
{"missing context key is vacuously true", map[string]any{}, allCond([]any{"dev"}), true},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionIfExistsTrustClaim covers a *IfExists operator
// against a custom claim: absent (vacuously allowed), present and matching
// (allowed), present and mismatched (denied).
func IAMAccessControl_ConditionIfExistsTrustClaim(s *S3Conf) error {
testName := "IAMAccessControl_ConditionIfExistsTrustClaim"
return iamActionHandler(s, testName, func(root *iam.Client) error {
ifExistsCond := func(value any) func(string) json.RawMessage {
return func(host string) json.RawMessage { return cond("StringEqualsIfExists", host+":department", value) }
}
cases := []federatedConditionCase{
{"absent key is vacuously allowed", map[string]any{}, ifExistsCond("eng"), true},
{"present matching key allowed", map[string]any{"department": "eng"}, ifExistsCond("eng"), true},
{"present mismatched key denied", map[string]any{"department": "sales"}, ifExistsCond("eng"), false},
}
return runFederatedConditionCases(root, s, cases)
})
}
// IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust verifies two
// separate operator blocks in the same trust-statement Condition (a
// StringEquals on sub and a NumericGreaterThan on a custom claim) are
// ANDed: both must be satisfied.
func IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust(s *S3Conf) error {
testName := "IAMAccessControl_ConditionMultipleOperatorBlocksANDedTrust"
return iamActionHandler(s, testName, func(root *iam.Client) error {
cases := []federatedConditionCase{
{
"both operator blocks satisfied allowed",
map[string]any{"sub": "user1", "level": 5},
func(host string) json.RawMessage {
return condAll(map[string]map[string]any{
"StringEquals": {host + ":sub": "user1"},
"NumericGreaterThan": {host + ":level": 3},
})
},
true,
},
{
"sub matches but level condition fails denies",
map[string]any{"sub": "user1", "level": 2},
func(host string) json.RawMessage {
return condAll(map[string]map[string]any{
"StringEquals": {host + ":sub": "user1"},
"NumericGreaterThan": {host + ":level": 3},
})
},
false,
},
}
return runFederatedConditionCases(root, s, cases)
})
}
// Principal-related authorization decisions are tested exclusively through
// role trust policies: an identity-based inline policy can never carry a
// Principal at all (PutUserPolicy/PutRolePolicy reject one outright), so
// there is nothing to test on that side. Within trust policies, only
// Principal.Federated is ever consulted at runtime — this gateway
// implements just sts:AssumeRoleWithWebIdentity, never a plain sts:AssumeRole
// or AssumeRoleWithSAML, so an "AWS" (IAM user/role/root/account) or
// "Service" principal, while accepted by write-time validation, has no
// runtime authorization meaning at all. IAMAccessControl_
// TrustPolicyNonFederatedPrincipalsIgnored demonstrates this divergence from
// real AWS directly. NotPrincipal is likewise grammar-recognized but
// unconditionally rejected at write time on both identity and trust
// policies (Allow and Deny alike), so no valid stored policy can ever carry
// one — there is no authorization decision to test, only a validation
// rejection, which is out of this suite's scope by design.
// IAMAccessControl_TrustPolicyFederatedExactMatchAllowed verifies a trust
// policy naming the exact registered OIDC provider ARN as its Federated
// principal allows assumption for a token issued by that provider.
func IAMAccessControl_TrustPolicyFederatedExactMatchAllowed(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyFederatedExactMatchAllowed"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyFederatedWrongProviderDenied verifies a trust
// policy federating a *real, registered* provider still denies a token
// issued by a *different* real, registered provider — an existing-but-
// mismatched principal, distinct from a dangling reference to a provider
// that was never created at all (see
// IAMAssumeRoleWithWebIdentity_no_matching_principal for that case).
func IAMAccessControl_TrustPolicyFederatedWrongProviderDenied(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyFederatedWrongProviderDenied"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, _, cleanupRole, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"})
}, nil)
if err != nil {
return err
}
defer cleanupRole()
otherProviderURL := newLoopbackOIDCURL()
otherProviderArn, err := createTestOIDCProviderWithURL(root, otherProviderURL)
if err != nil {
return err
}
defer deleteOIDCProvider(root, otherProviderArn)
token := mustToken(map[string]any{"iss": otherProviderURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
return wantTrustDeniedInvalidClaims(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyFederatedArrayMatchesAny verifies a Federated
// principal given as an array of provider ARNs matches a token issued by
// *either* one.
func IAMAccessControl_TrustPolicyFederatedArrayMatchesAny(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyFederatedArrayMatchesAny"
return iamActionHandler(s, testName, func(root *iam.Client) error {
firstURL := newLoopbackOIDCURL()
firstArn, err := createTestOIDCProviderWithURL(root, firstURL)
if err != nil {
return err
}
defer deleteOIDCProvider(root, firstArn)
roleArn, secondURL, cleanupRole, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": []string{firstArn, providerArn}}, Action: "sts:AssumeRoleWithWebIdentity",
})
}, nil)
if err != nil {
return err
}
defer cleanupRole()
// A token from the *second* array entry (not the first) still matches.
token := mustToken(map[string]any{"iss": secondURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored documents a
// meaningful divergence from real AWS IAM: this gateway's only
// AssumeRole-family action is AssumeRoleWithWebIdentity, so
// EvaluateWebIdentityTrust only ever inspects a statement's
// Principal.Federated value — an "AWS" principal (even a wildcard "*", or a
// literal account root ARN, both of which would grant real AWS's plain
// sts:AssumeRole) or a "Service" principal is accepted by write-time
// validation but has no runtime effect: a role trusting *only* one of these
// can never actually be assumed by anyone, denied exactly as if the trust
// policy had no usable principal at all.
func IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyNonFederatedPrincipalsIgnored"
return iamActionHandler(s, testName, func(root *iam.Client) error {
cases := []struct {
name string
principal any
}{
{"AWS wildcard principal alone", map[string]any{"AWS": "*"}},
{"AWS root account principal alone", map[string]any{"AWS": "arn:aws:iam::" + testAccountID + ":root"}},
{"Service principal alone", map[string]any{"Service": "sts.amazonaws.com"}},
}
for _, tc := range cases {
if err := func() error {
roleName := "ac-nonfed-" + genRandString(12)
trust := trustDoc(trustStatement{Effect: "Allow", Principal: tc.principal, Action: "sts:AssumeRoleWithWebIdentity"})
if _, err := createIAMRole(root, &iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)}); err != nil {
return err
}
defer deleteIAMRole(root, roleName)
roleArn := "arn:aws:iam::" + testAccountID + ":role/" + roleName
token := mustToken(map[string]any{"iss": "https://unused.example.com", "aud": "client1", "sub": "user1", "exp": 9999999999})
return wantTrustDeniedNoPrincipal(s, roleArn, token)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed verifies a
// StringEquals condition on <provider>:sub allows a token whose subject
// matches exactly.
func IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyStringEqualsSubjectExactAllowed"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":sub", "repo:my-org/my-repo:ref:refs/heads/main"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:ref:refs/heads/main"})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied is the
// StringEqualsSubjectExactAllowed companion: a different repository's
// subject is denied.
func IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyStringEqualsSubjectMismatchDenied"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":sub", "repo:my-org/my-repo:ref:refs/heads/main"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/other-repo:ref:refs/heads/main"})
return wantTrustDeniedInvalidClaims(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed verifies a
// StringLike condition on <provider>:sub with a trailing wildcard allows any
// branch under refs/heads/ — a realistic GitHub-Actions-style pattern.
func IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyStringLikeBranchWildcardAllowed"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringLike", host+":sub", "repo:my-org/my-repo:ref:refs/heads/*"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:ref:refs/heads/feature-x"})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied is the
// StringLikeBranchWildcardAllowed companion: a pull-request-triggered
// subject (a different sub shape entirely, not matching the refs/heads/*
// pattern) is denied.
func IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyStringLikeTagSubjectDenied"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringLike", host+":sub", "repo:my-org/my-repo:ref:refs/heads/*"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "exp": 9999999999, "sub": "repo:my-org/my-repo:pull_request"})
return wantTrustDeniedInvalidClaims(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyAudienceCorrectAllowed verifies a StringEquals
// condition on <provider>:aud allows a token whose (ClientIDList-valid)
// audience matches the condition's expected value. The provider's
// ClientIDList registers *two* acceptable audiences so this and
// AudienceIncorrectDenied can each present a ClientIDList-valid audience,
// isolating the Condition itself as what's actually under test (see
// newFederatedRole's doc comment).
func IAMAccessControl_TrustPolicyAudienceCorrectAllowed(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyAudienceCorrectAllowed"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "other-aud"}, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":aud", "expected-aud"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": "expected-aud", "sub": "user1", "exp": 9999999999})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyAudienceIncorrectDenied is the
// AudienceCorrectAllowed companion: an audience that's valid per
// ClientIDList but doesn't match the trust policy's Condition is denied.
func IAMAccessControl_TrustPolicyAudienceIncorrectDenied(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyAudienceIncorrectDenied"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "other-aud"}, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":aud", "expected-aud"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": "other-aud", "sub": "user1", "exp": 9999999999})
return wantTrustDeniedInvalidClaims(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed verifies a
// StringEquals condition on <provider>:aud with an array of acceptable
// values matches any one of them.
func IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyMultipleAudiencesArrayAllowed"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"aud-one", "aud-two"}, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":aud", []string{"aud-one", "aud-two"}),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": "aud-two", "sub": "user1", "exp": 9999999999})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch verifies a
// trust statement with Conditions on both <provider>:aud and <provider>:sub
// requires both to match — either alone is not enough.
func IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyAudienceAndSubjectBothMustMatch"
return iamActionHandler(s, testName, func(root *iam.Client) error {
cases := []struct {
name string
aud, sub string
wantAllowed bool
}{
{"both match allowed", "expected-aud", "expected-sub", true},
{"only audience matches denied", "expected-aud", "wrong-sub", false},
{"only subject matches denied", "wrong-aud", "expected-sub", false},
{"neither matches denied", "wrong-aud", "wrong-sub", false},
}
for _, tc := range cases {
if err := func() error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, []string{"expected-aud", "wrong-aud"}, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: condAll(map[string]map[string]any{
"StringEquals": {host + ":aud": "expected-aud", host + ":sub": "expected-sub"},
}),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": tc.aud, "sub": tc.sub, "exp": 9999999999})
if tc.wantAllowed {
return wantTrustAllowed(s, roleArn, token)
}
return wantTrustDeniedInvalidClaims(s, roleArn, token)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_TrustPolicyExplicitDenyStatement verifies an explicit
// Deny statement scoped to one subject blocks assumption for that subject
// while a broader Allow still covers every other subject.
func IAMAccessControl_TrustPolicyExplicitDenyStatement(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyExplicitDenyStatement"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(
trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"},
trustStatement{
Effect: "Deny", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":sub", "blocked-user"),
},
)
}, nil)
if err != nil {
return err
}
defer cleanup()
blockedToken := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "blocked-user", "exp": 9999999999})
if err := wantTrustDeniedExplicit(s, roleArn, blockedToken); err != nil {
return fmt.Errorf("blocked subject: %w", err)
}
allowedToken := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "someone-else", "exp": 9999999999})
if err := wantTrustAllowed(s, roleArn, allowedToken); err != nil {
return fmt.Errorf("non-blocked subject: %w", err)
}
return nil
})
}
// IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants verifies a
// trust policy is evaluated statement by statement across the whole
// document: a first statement referencing an unrelated provider doesn't
// prevent a second statement (for the *actual* issuer) from granting
// assumption.
func IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyMultipleStatementsSecondGrants"
return iamActionHandler(s, testName, func(root *iam.Client) error {
unrelatedURL := newLoopbackOIDCURL()
unrelatedArn, err := createTestOIDCProviderWithURL(root, unrelatedURL)
if err != nil {
return err
}
defer deleteOIDCProvider(root, unrelatedArn)
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(
trustStatement{Sid: "Unrelated", Effect: "Allow", Principal: map[string]any{"Federated": unrelatedArn}, Action: "sts:AssumeRoleWithWebIdentity"},
trustStatement{Sid: "Actual", Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"},
)
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
return wantTrustAllowed(s, roleArn, token)
})
}
// IAMAccessControl_TrustPolicyMissingRequiredClaimDenied verifies a
// StringEquals condition against a claim key the token simply never carries
// denies assumption — a positive (non-IfExists) operator against an absent
// key fails closed (see IAMAccessControl_ConditionIfExistsTrustClaim for
// the IfExists variant's opposite behavior on the same kind of absence).
func IAMAccessControl_TrustPolicyMissingRequiredClaimDenied(s *S3Conf) error {
testName := "IAMAccessControl_TrustPolicyMissingRequiredClaimDenied"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":employee_id", "12345"),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
// The token never includes an employee_id claim at all.
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
return wantTrustDeniedInvalidClaims(s, roleArn, token)
})
}
// IAMAccessControl_UserInlinePolicyWorkflow exercises the full lifecycle a
// user's inline policy goes through: create two users (one caller, one
// target), attach an inline policy scoped to a condition on the caller's
// own identity, create access keys, make signed calls as the caller,
// verify the permitted action+resource succeeds, verify denial for another
// action, another user resource, a condition mismatch (a second,
// differently-named caller under the same policy shape), and an explicit
// Deny, then update the policy and verify the changed authorization takes
// effect while the explicit Deny still holds.
func IAMAccessControl_UserInlinePolicyWorkflow(s *S3Conf) error {
testName := "IAMAccessControl_UserInlinePolicyWorkflow"
return iamActionHandler(s, testName, func(root *iam.Client) error {
targetName, targetArn, cleanupTarget, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupTarget()
otherName, otherArn, cleanupOther, err := newTargetUser(root)
if err != nil {
return err
}
defer cleanupOther()
callerName := "ac-workflow-" + genRandString(10)
grant := func(callerUserName string) string {
return policyDoc(
accessStatement{Sid: "AllowGetTarget", Effect: "Allow", Action: actGetUser, Resource: targetArn,
Condition: cond("StringEquals", "aws:username", callerUserName)},
accessStatement{Sid: "DenyDeletePolicy", Effect: "Deny", Action: actDeleteUserPolicy, Resource: "*"},
)
}
caller, cleanupCaller, err := newAccessControlCaller(root, s, callerName, map[string]string{"grant": grant(callerName)})
if err != nil {
return err
}
defer cleanupCaller()
// Permitted action + resource succeeds, and genuinely returns the
// target's data (not just a nil error).
getOut, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(targetName)})
if err := wantAllowed(caller.arn, actGetUser, targetArn, err); err != nil {
return fmt.Errorf("permitted action+resource: %w", err)
}
if getOut == nil || getOut.User == nil || aws.ToString(getOut.User.UserName) != targetName {
return fmt.Errorf("expected GetUser to return user %q, got %#v", targetName, getOut)
}
// Another action against the same resource is denied.
if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantDenied(caller.arn, actListUserPolicies, targetArn, err) != nil {
return fmt.Errorf("another action: %w", wantDenied(caller.arn, actListUserPolicies, targetArn, err))
}
// The same permitted action against a different user resource is denied.
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(otherName)}); wantDenied(caller.arn, actGetUser, otherArn, err) != nil {
return fmt.Errorf("another resource: %w", wantDenied(caller.arn, actGetUser, otherArn, err))
}
// A condition mismatch (a caller whose own username differs from what
// the policy's Condition expects) is denied even under the identical
// policy shape.
mismatchName := "ac-workflow-" + genRandString(10)
mismatchCaller, cleanupMismatch, err := newAccessControlCaller(root, s, mismatchName, map[string]string{"grant": grant(callerName)})
if err != nil {
return err
}
defer cleanupMismatch()
if _, err := getIAMUser(mismatchCaller.client, &iam.GetUserInput{UserName: aws.String(targetName)}); wantDenied(mismatchCaller.arn, actGetUser, targetArn, err) != nil {
return fmt.Errorf("condition mismatch: %w", wantDenied(mismatchCaller.arn, actGetUser, targetArn, err))
}
// An explicit Deny blocks an action the broad wildcard Resource on
// that statement would otherwise apply to, regardless of what the
// named policy/resource actually is.
_, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")})
if err := wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err); err != nil {
return fmt.Errorf("explicit deny: %w", err)
}
// Updating the policy to grant the previously-denied action takes
// effect immediately.
updated := policyDoc(
accessStatement{Sid: "AllowGetTarget", Effect: "Allow", Action: []string{actGetUser, actListUserPolicies}, Resource: targetArn,
Condition: cond("StringEquals", "aws:username", callerName)},
accessStatement{Sid: "DenyDeletePolicy", Effect: "Deny", Action: actDeleteUserPolicy, Resource: "*"},
)
if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{
UserName: aws.String(caller.userName), PolicyName: aws.String("grant"), PolicyDocument: aws.String(updated),
}); err != nil {
return fmt.Errorf("update policy: %w", err)
}
if _, err := listIAMUserPolicies(caller.client, &iam.ListUserPoliciesInput{UserName: aws.String(targetName)}); wantAllowed(caller.arn, actListUserPolicies, targetArn, err) != nil {
return fmt.Errorf("newly granted action after update: %w", wantAllowed(caller.arn, actListUserPolicies, targetArn, err))
}
// The explicit Deny is still in effect after the update.
_, err = deleteIAMUserPolicyRaw(caller.client, &iam.DeleteUserPolicyInput{UserName: aws.String(targetName), PolicyName: aws.String("irrelevant")})
if err := wantDenied(caller.arn, actDeleteUserPolicy, targetArn, err); err != nil {
return fmt.Errorf("explicit deny after update: %w", err)
}
return nil
})
}
// IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath verifies a
// resource pattern scoped to one path prefix grants access to users under
// that path but not to a user with a different path, even with an
// otherwise-identical name prefix.
func IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath(s *S3Conf) error {
testName := "IAMAccessControl_UserPathScopedResourceGrantsOnlyMatchingPath"
return iamActionHandler(s, testName, func(root *iam.Client) error {
inPathName, inPathArn, cleanupInPath, err := newTargetUserWithPath(root, "/ac-finance/")
if err != nil {
return err
}
defer cleanupInPath()
outOfPathName, outOfPathArn, cleanupOutOfPath, err := newTargetUserWithPath(root, "/ac-marketing/")
if err != nil {
return err
}
defer cleanupOutOfPath()
policy := policyDoc(accessStatement{Effect: "Allow", Action: actGetUser, Resource: "arn:aws:iam::" + testAccountID + ":user/ac-finance/*"})
caller, cleanupCaller, err := newAccessControlCaller(root, s, "", map[string]string{"p": policy})
if err != nil {
return err
}
defer cleanupCaller()
if _, err := getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(inPathName)}); wantAllowed(caller.arn, actGetUser, inPathArn, err) != nil {
return fmt.Errorf("in-path user: %w", wantAllowed(caller.arn, actGetUser, inPathArn, err))
}
_, err = getIAMUser(caller.client, &iam.GetUserInput{UserName: aws.String(outOfPathName)})
if err := wantDenied(caller.arn, actGetUser, outOfPathArn, err); err != nil {
return fmt.Errorf("out-of-path user: %w", err)
}
return nil
})
}
// IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision
// demonstrates that trust-policy authorization and role-permission
// authorization are separate stages: a role's inline (permission) policy —
// absent, permissive, or deny-all — has no bearing on whether the role can
// be assumed. Every variant reaches the identical trust-evaluation outcome
// (this suite's network-stage proxy for "Allowed", per the file doc
// comment) with the trust policy held fixed.
func IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision(s *S3Conf) error {
testName := "IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision"
return iamActionHandler(s, testName, func(root *iam.Client) error {
cases := []struct {
name string
rolePermission map[string]string
}{
{"no permission policy at all", nil},
{"broad permissive permission policy", map[string]string{"perm": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"})}},
{"deny-all permission policy", map[string]string{"perm": policyDoc(accessStatement{Effect: "Deny", Action: "iam:*", Resource: "*"})}},
}
for _, tc := range cases {
if err := func() error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"})
}, tc.rolePermission)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
return wantTrustAllowed(s, roleArn, token)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy is the
// converse of RolePermissionPolicyDoesNotAffectAssumptionDecision: even a
// maximally permissive role permission policy cannot compensate for a trust
// policy that doesn't authorize the caller — assumption is still denied.
func IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy(s *S3Conf) error {
testName := "IAMAccessControl_RoleTrustDenialIndependentOfPermissionPolicy"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, _, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
host := trimProviderScheme(providerURL)
return trustDoc(trustStatement{
Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity",
Condition: cond("StringEquals", host+":sub", "expected-user"),
})
}, map[string]string{"perm": policyDoc(accessStatement{Effect: "Allow", Action: "iam:*", Resource: "*"})})
if err != nil {
return err
}
defer cleanup()
// A different subject: trust Condition fails despite the role's own
// permission policy granting everything.
token := mustToken(map[string]any{"iss": "https://unused-in-this-assertion.example.com", "aud": defaultTestAudience[0], "sub": "someone-else", "exp": 9999999999})
return wantTrustDeniedInvalidClaims(s, roleArn, token)
})
}
// IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer
// verifies isolation between two independently-configured federated roles:
// a token issued for role A's provider cannot assume role B, even though it
// can (still) assume role A.
func IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer(s *S3Conf) error {
testName := "IAMAccessControl_CrossIdentity_UnrelatedRoleCannotBeAssumedViaWrongIssuer"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleAArn, providerAURL, cleanupA, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"})
}, nil)
if err != nil {
return err
}
defer cleanupA()
roleBArn, _, cleanupB, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"})
}, nil)
if err != nil {
return err
}
defer cleanupB()
tokenForA := mustToken(map[string]any{"iss": providerAURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
if err := wantTrustAllowed(s, roleAArn, tokenForA); err != nil {
return fmt.Errorf("token still assumes its own role: %w", err)
}
if err := wantTrustDeniedInvalidClaims(s, roleBArn, tokenForA); err != nil {
return fmt.Errorf("same token cannot assume an unrelated role: %w", err)
}
return nil
})
}
// IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck
// documents a meaningful divergence from real AWS's plain sts:AssumeRole:
// this gateway's only assume-role action is unauthenticated (see
// stsOpenRoute in iamapi/router.go — VerifyIAMAuth never runs for it), so
// there is no calling IAM identity and thus no identity-based-policy check
// on the assumption call itself, only the target role's trust policy. This
// is demonstrated by showing an identical trust/token pair produces an
// identical result (the same network-dependent failure this suite uses
// throughout as its proxy for reaching a genuine Allowed decision — see the
// file doc comment) whether the request is signed with the real root
// credential or with a completely arbitrary, nonexistent access key: if
// caller identity mattered here, at least one of these would fail
// differently (e.g. an unknown-access-key error) instead of both reaching
// the identical outcome.
func IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck(s *S3Conf) error {
testName := "IAMAccessControl_CrossIdentity_AssumeRoleWithWebIdentityHasNoCallerIdentityCheck"
return iamActionHandler(s, testName, func(root *iam.Client) error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, _ string) string {
return trustDoc(trustStatement{Effect: "Allow", Principal: map[string]any{"Federated": providerArn}, Action: "sts:AssumeRoleWithWebIdentity"})
}, nil)
if err != nil {
return err
}
defer cleanup()
token := mustToken(map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999})
if err := wantTrustAllowed(s, roleArn, token); err != nil {
return fmt.Errorf("signed with the real root credential: %w", err)
}
bogusCfg := *s
bogusCfg.awsID, bogusCfg.awsSecret = "AKIA"+genRandString(16), genRandString(32)
if err := wantTrustAllowed(&bogusCfg, roleArn, token); err != nil {
return fmt.Errorf("signed with an arbitrary, nonexistent access key: %w", err)
}
return nil
})
}
// accessControlCaller is an isolated IAM user with its own long-term access
// key, used as the authenticated caller for an identity-policy authorization
// test.
type accessControlCaller struct {
userName string
userID string
arn string
client *iam.Client
}
// newAccessControlCaller creates an isolated IAM user (userName, or an
// auto-generated one if empty), attaches the given named inline policies
// (policyName -> document; may be nil/empty), creates one long-term access
// key, and returns an *iam.Client authenticated as that user plus a cleanup
// func that removes the key, every attached policy, and the user itself.
func newAccessControlCaller(root *iam.Client, s *S3Conf, userName string, policies map[string]string) (*accessControlCaller, func(), error) {
return newAccessControlCallerTagged(root, s, userName, policies, nil)
}
// newAccessControlCallerTagged is newAccessControlCaller plus tags on the
// created user, for aws:PrincipalTag/Null/IfExists-style tests.
func newAccessControlCallerTagged(root *iam.Client, s *S3Conf, userName string, policies map[string]string, tags map[string]string) (*accessControlCaller, func(), error) {
if userName == "" {
userName = newIAMUserName()
}
input := &iam.CreateUserInput{UserName: aws.String(userName)}
for k, v := range tags {
input.Tags = append(input.Tags, iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)})
}
createOut, err := createIAMUser(root, input)
if err != nil {
return nil, nil, fmt.Errorf("create caller user: %w", err)
}
for name, doc := range policies {
if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{
UserName: aws.String(userName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc),
}); err != nil {
deleteIAMUser(root, userName)
return nil, nil, fmt.Errorf("attach caller policy %q: %w", name, err)
}
}
keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)})
if err != nil {
deleteAccessControlCaller(root, userName)
return nil, nil, fmt.Errorf("create caller access key: %w", err)
}
caller := &accessControlCaller{
userName: userName,
userID: aws.ToString(createOut.User.UserId),
arn: aws.ToString(createOut.User.Arn),
client: iamClientWithCreds(s, aws.ToString(keyOut.AccessKey.AccessKeyId), aws.ToString(keyOut.AccessKey.SecretAccessKey), ""),
}
cleanup := func() { deleteAccessControlCaller(root, userName) }
return caller, cleanup, nil
}
// deleteAccessControlCaller removes every dependency DeleteUser would
// otherwise reject (inline policies, access keys) before deleting the user
// itself. Neither of the existing deleteIAMUserAndPolicies/
// deleteIAMUserAndAccessKeys helpers alone covers the combination
// newAccessControlCaller's fixtures always create (both policies and a
// key), so this file needs its own.
func deleteAccessControlCaller(root *iam.Client, userName string) error {
polOut, err := listIAMUserPolicies(root, &iam.ListUserPoliciesInput{UserName: aws.String(userName)})
if err != nil {
return err
}
for _, name := range polOut.PolicyNames {
if err := deleteIAMUserPolicy(root, userName, name); err != nil {
return err
}
}
keyOut, err := listIAMAccessKeys(root, &iam.ListAccessKeysInput{UserName: aws.String(userName)})
if err != nil {
return err
}
for _, key := range keyOut.AccessKeyMetadata {
if err := deleteIAMAccessKey(root, userName, aws.ToString(key.AccessKeyId)); err != nil {
return err
}
}
return deleteIAMUser(root, userName)
}
// newTargetUser creates a plain, isolated IAM user with no policies of its
// own, to be used as the resource another caller's policy is tested
// against.
func newTargetUser(root *iam.Client) (userName, arn string, cleanup func(), err error) {
return newTargetUserWithPath(root, "")
}
// newTargetUserWithPath is newTargetUser with an explicit Path, for
// resource-path-wildcard tests.
func newTargetUserWithPath(root *iam.Client, path string) (userName, arn string, cleanup func(), err error) {
userName = "ac-target-" + genRandString(12)
input := &iam.CreateUserInput{UserName: aws.String(userName)}
if path != "" {
input.Path = aws.String(path)
}
out, err := createIAMUser(root, input)
if err != nil {
return "", "", nil, err
}
return userName, aws.ToString(out.User.Arn), func() { deleteIAMUser(root, userName) }, nil
}
// newTargetUserTagged is newTargetUser plus tags, for
// iam:ResourceTag/aws:ResourceTag condition tests.
func newTargetUserTagged(root *iam.Client, tags map[string]string) (userName, arn string, cleanup func(), err error) {
userName = "ac-target-" + genRandString(12)
input := &iam.CreateUserInput{UserName: aws.String(userName)}
for k, v := range tags {
input.Tags = append(input.Tags, iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)})
}
out, err := createIAMUser(root, input)
if err != nil {
return "", "", nil, err
}
return userName, aws.ToString(out.User.Arn), func() { deleteIAMUser(root, userName) }, nil
}
// newTargetRole creates a plain role (permissive default trust policy, no
// inline policies) to be used as the resource another caller's policy is
// tested against.
func newTargetRole(root *iam.Client) (roleName, arn string, cleanup func(), err error) {
roleName = "ac-target-role-" + genRandString(12)
if _, err = createIAMRole(root, &iam.CreateRoleInput{
RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument),
}); err != nil {
return "", "", nil, err
}
return roleName, "arn:aws:iam::" + testAccountID + ":role/" + roleName, func() { deleteIAMRole(root, roleName) }, nil
}
// iamClientWithCreds builds an *iam.Client authenticated as the given
// access/secret/session-token triple, reusing s's endpoint/region/http
// client. S3Conf has no session-token field of its own (only
// AssumeRoleWithWebIdentity-derived credentials would ever need one, and
// this file never gets that far — see the file doc comment), so every call
// site here passes token="" — but the parameter exists so this stays
// reusable if that ever changes.
func iamClientWithCreds(s *S3Conf, access, secret, token string) *iam.Client {
cfg := s.Config()
cfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token)
return iam.NewFromConfig(cfg)
}
// getIAMUser is the GetUser counterpart to the existing getIAMRole/
// getIAMUserPolicy/getIAMRolePolicy helpers elsewhere in this package — no
// prior test file needed a generic wrapper for it.
func getIAMUser(client *iam.Client, input *iam.GetUserInput) (*iam.GetUserOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.GetUser(ctx, input)
}
// wantAllowed reports a descriptive error if err is non-nil, identifying the
// caller, action, and resource a test expected to be authorized.
func wantAllowed(callerArn, action, resource string, err error) error {
if err != nil {
return fmt.Errorf("caller=%s action=%s resource=%s: expected ALLOW, got error: %v", callerArn, action, resource, err)
}
return nil
}
// wantDenied asserts err is exactly the AccessDenied error VerifyIAMPolicy
// produces for callerArn/action — not merely "some error" (a wrong ARN, a
// missing parameter, or a not-found resource must not be mistaken for an
// authorization denial).
func wantDenied(callerArn, action, resource string, err error) error {
if cerr := checkIAMApiErr(err, iamerr.AccessDeniedIAMAction(callerArn, action)); cerr != nil {
return fmt.Errorf("caller=%s action=%s resource=%s: expected DENY: %w", callerArn, action, resource, cerr)
}
return nil
}
// accessStatement is a safe, type-checked builder for one identity-policy
// statement — used instead of hand-formatted JSON strings so a test typo
// produces a Go compile error or a visibly-wrong marshaled document instead
// of a silently-malformed policy. Action/NotAction/Resource/NotResource
// accept either a bare string or a []string (both marshal the way this
// gateway's StringOrSlice unmarshals them).
type accessStatement struct {
Sid string `json:"Sid,omitempty"`
Effect string `json:"Effect"`
Action any `json:"Action,omitempty"`
NotAction any `json:"NotAction,omitempty"`
Resource any `json:"Resource,omitempty"`
NotResource any `json:"NotResource,omitempty"`
Condition json.RawMessage `json:"Condition,omitempty"`
}
// policyDoc marshals statements into a complete "2012-10-17" identity-policy
// document string. Marshaling a fixed struct of strings/[]string/
// json.RawMessage cannot fail in practice; a panic here means a test itself
// is malformed, not a runtime condition to recover from.
func policyDoc(statements ...accessStatement) string {
doc := struct {
Version string `json:"Version"`
Statement []accessStatement `json:"Statement"`
}{"2012-10-17", statements}
b, err := json.Marshal(doc)
if err != nil {
panic(fmt.Sprintf("iam_access_control: policyDoc: %v", err))
}
return string(b)
}
// trustStatement is accessStatement's counterpart for role trust policies:
// Principal is required (never NotPrincipal — see the file's Principal
// section for why versitygw rejects NotPrincipal unconditionally), and
// Resource/NotResource don't exist in trust-policy grammar at all.
type trustStatement struct {
Sid string `json:"Sid,omitempty"`
Effect string `json:"Effect"`
Principal any `json:"Principal"`
Action any `json:"Action,omitempty"`
NotAction any `json:"NotAction,omitempty"`
Condition json.RawMessage `json:"Condition,omitempty"`
}
func trustDoc(statements ...trustStatement) string {
doc := struct {
Version string `json:"Version"`
Statement []trustStatement `json:"Statement"`
}{"2012-10-17", statements}
b, err := json.Marshal(doc)
if err != nil {
panic(fmt.Sprintf("iam_access_control: trustDoc: %v", err))
}
return string(b)
}
// cond builds a Condition block containing a single operator/key/value(s)
// entry, e.g. cond("StringEquals", "aws:username", "alice") or
// cond("StringEquals", "aws:username", []string{"alice", "bob"}).
func cond(operator, key string, value any) json.RawMessage {
b, err := json.Marshal(map[string]map[string]any{operator: {key: value}})
if err != nil {
panic(fmt.Sprintf("iam_access_control: cond: %v", err))
}
return b
}
// condAll builds a Condition block from multiple operator blocks and/or
// multiple keys within a block, for multi-condition-semantics tests (see
// evaluateCondition's AND-across-operators/keys, OR-across-values
// semantics).
func condAll(blocks map[string]map[string]any) json.RawMessage {
b, err := json.Marshal(blocks)
if err != nil {
panic(fmt.Sprintf("iam_access_control: condAll: %v", err))
}
return b
}
// mustToken wraps webIdentityTokenWithClaims for call sites that pass fixed,
// well-formed claims — a marshal failure there means a test itself is
// malformed, not a runtime condition.
func mustToken(claims map[string]any) string {
tok, err := webIdentityTokenWithClaims(claims)
if err != nil {
panic(fmt.Sprintf("iam_access_control: mustToken: %v", err))
}
return tok
}
// newLoopbackOIDCURL returns a random loopback-IP-based OIDC provider URL.
// Every trust-policy test in this file that needs to observe an "Allowed"
// decision (see the file doc comment) federates a loopback provider so
// evaluation deterministically fails at the network-dependent signature step
// instead of hanging or attempting real internet access. A random address,
// rather than a fixed one like 127.0.0.1, keeps concurrently-running
// subtests from colliding on the same provider identity.
func newLoopbackOIDCURL() string {
return fmt.Sprintf("https://127.%d.%d.%d", 1+rand.Intn(254), 1+rand.Intn(254), 1+rand.Intn(254))
}
// newFederatedRole creates a fresh OIDC provider at a random loopback URL
// (see newLoopbackOIDCURL) with the given ClientIDList, then a role whose
// trust policy is buildTrust(providerArn, providerURL) — buildTrust is
// handed both so it can reference the provider as a Federated principal and
// build "<host>:<claim>"-style Condition keys (via trimProviderScheme).
// rolePolicies (may be nil) are attached as the role's inline *permission*
// policies; several tests in this file deliberately vary these (empty,
// permissive, deny-all) while holding the trust policy fixed, to
// demonstrate that a role's permission policy has no bearing on whether it
// can be assumed — only its trust policy does (see
// IAMAccessControl_RolePermissionPolicyDoesNotAffectAssumptionDecision).
func newFederatedRole(root *iam.Client, clientIDs []string, buildTrust func(providerArn, providerURL string) string, rolePolicies map[string]string) (roleArn, providerURL string, cleanup func(), err error) {
providerURL = newLoopbackOIDCURL()
out, err := createOIDCProvider(root, &iam.CreateOpenIDConnectProviderInput{
Url: aws.String(providerURL),
ClientIDList: clientIDs,
ThumbprintList: []string{validOIDCThumbprint},
})
if err != nil {
return "", "", nil, fmt.Errorf("create provider: %w", err)
}
providerArn := aws.ToString(out.OpenIDConnectProviderArn)
roleName := "ac-role-" + genRandString(12)
trust := buildTrust(providerArn, providerURL)
if _, err := createIAMRole(root, &iam.CreateRoleInput{RoleName: aws.String(roleName), AssumeRolePolicyDocument: aws.String(trust)}); err != nil {
deleteOIDCProvider(root, providerArn)
return "", "", nil, fmt.Errorf("create role: %w", err)
}
for name, doc := range rolePolicies {
if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{
RoleName: aws.String(roleName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc),
}); err != nil {
deleteIAMRoleAndPolicies(root, roleName)
deleteOIDCProvider(root, providerArn)
return "", "", nil, fmt.Errorf("attach role policy %q: %w", name, err)
}
}
roleArn = "arn:aws:iam::" + testAccountID + ":role/" + roleName
cleanup = func() {
deleteIAMRoleAndPolicies(root, roleName)
deleteOIDCProvider(root, providerArn)
}
return roleArn, providerURL, cleanup, nil
}
// wantTrustAllowed asserts that assuming roleArn with token reaches the
// network-dependent signature-verification stage — this suite's
// deterministic, black-box-observable proxy for "trust policy evaluation
// returned Allowed" (see the file doc comment). roleArn's trust policy must
// federate a loopback-URL provider (see newLoopbackOIDCURL/newFederatedRole)
// for the network step to fail deterministically instead of hanging or
// attempting real internet access.
func wantTrustAllowed(s *S3Conf, roleArn, token string) error {
_, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0)
return checkIAMApiErr(err, iamerr.InvalidIdentityTokenIDPCommunicationError())
}
// wantTrustDeniedNoPrincipal asserts assumption fails the way it does when
// no statement's Federated principal resolves to a provider that actually
// exists (policy.NoPrincipal) — the same AccessDenied outcome AWS also uses
// for a role that doesn't exist at all, never confirming or denying which.
func wantTrustDeniedNoPrincipal(s *S3Conf, roleArn, token string) error {
_, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0)
return checkIAMApiErr(err, iamerr.AccessDeniedAssumeRoleWithWebIdentity())
}
// wantTrustDeniedExplicit asserts assumption fails via an explicit Deny
// statement (policy.ExplicitlyDenied) — also AccessDenied, but reached via a
// different evaluation path than wantTrustDeniedNoPrincipal (a real,
// existing, issuer-matching provider whose statement actively denies, not an
// unresolvable principal).
func wantTrustDeniedExplicit(s *S3Conf, roleArn, token string) error {
_, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0)
return checkIAMApiErr(err, iamerr.AccessDeniedAssumeRoleWithWebIdentity())
}
// wantTrustDeniedInvalidClaims asserts assumption fails at the claims stage
// (policy.NoIssuerMatch or policy.ConditionFailed) — an existing, correctly
// Federated provider whose Condition (or, elsewhere in this package,
// audience/issuer) didn't satisfy the request.
func wantTrustDeniedInvalidClaims(s *S3Conf, roleArn, token string) error {
_, err := assumeRoleWithWebIdentity(s, roleArn, "ac-session-"+genRandString(8), token, 0)
return checkIAMApiErr(err, iamerr.InvalidIdentityTokenClaims())
}
// federatedConditionCase is one row of a table-driven trust-policy Condition
// test: a JWT claim (merged over the base iss/aud/sub/exp claims
// runFederatedConditionCases always supplies) paired with the Condition
// block a role's trust policy scopes, and whether that combination should
// let evaluation reach the network stage (wantTrustAllowed's proxy for
// "Allowed") or fail with InvalidIdentityTokenClaims.
type federatedConditionCase struct {
name string
claims map[string]any
condition func(host string) json.RawMessage
wantAllowed bool
}
// runFederatedConditionCases runs each case against its own fresh
// provider/role (see newFederatedRole), always using defaultTestAudience so
// a case's outcome is driven solely by its own condition/claim, never an
// incidental audience mismatch.
func runFederatedConditionCases(root *iam.Client, s *S3Conf, cases []federatedConditionCase) error {
for _, tc := range cases {
if err := func() error {
roleArn, providerURL, cleanup, err := newFederatedRole(root, defaultTestAudience, func(providerArn, providerURL string) string {
return trustDoc(trustStatement{
Effect: "Allow",
Principal: map[string]any{"Federated": providerArn},
Action: "sts:AssumeRoleWithWebIdentity",
Condition: tc.condition(trimProviderScheme(providerURL)),
})
}, nil)
if err != nil {
return err
}
defer cleanup()
claims := map[string]any{"iss": providerURL, "aud": defaultTestAudience[0], "sub": "user1", "exp": 9999999999}
for k, v := range tc.claims {
claims[k] = v
}
token := mustToken(claims)
if tc.wantAllowed {
return wantTrustAllowed(s, roleArn, token)
}
return wantTrustDeniedInvalidClaims(s, roleArn, token)
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
}
// callerSourceIP returns the IP address the gateway will observe as
// aws:SourceIp for requests made through s's configured endpoint — derived
// from the endpoint's own host rather than assumed, since loopback
// connections use the destination address as their source (no NAT), and the
// integration harness always points s's endpoint at a literal loopback IP
// (see runiamtests.sh). Returns an error rather than guessing if the
// endpoint's host isn't a literal IP, so an IP-condition test fails loudly
// instead of silently asserting against the wrong address.
func callerSourceIP(s *S3Conf) (string, error) {
u, err := url.Parse(s.endpoint)
if err != nil {
return "", fmt.Errorf("parse endpoint %q: %w", s.endpoint, err)
}
host := u.Hostname()
if host == "" {
return "", fmt.Errorf("endpoint %q has no host", s.endpoint)
}
return host, nil
}