diff --git a/spinifex/gateway/iam/group.go b/spinifex/gateway/iam/group.go index 38a6b1288..087173981 100644 --- a/spinifex/gateway/iam/group.go +++ b/spinifex/gateway/iam/group.go @@ -34,6 +34,9 @@ func GetGroup(accountID string, input *iam.GetGroupInput, svc handlers_iam.IAMSe } func ListGroups(accountID string, input *iam.ListGroupsInput, svc handlers_iam.IAMService) (*iam.ListGroupsOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -115,6 +118,9 @@ func ListAttachedGroupPolicies(accountID string, input *iam.ListAttachedGroupPol if input.GroupName == nil || *input.GroupName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/instance_profile.go b/spinifex/gateway/iam/instance_profile.go index 3c6e8ef6b..59032f502 100644 --- a/spinifex/gateway/iam/instance_profile.go +++ b/spinifex/gateway/iam/instance_profile.go @@ -34,6 +34,9 @@ func GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput, sv } func ListInstanceProfiles(accountID string, input *iam.ListInstanceProfilesInput, svc handlers_iam.IAMService) (*iam.ListInstanceProfilesOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/pagination_test.go b/spinifex/gateway/iam/pagination_test.go index 9d598d6da..d550ae2b0 100644 --- a/spinifex/gateway/iam/pagination_test.go +++ b/spinifex/gateway/iam/pagination_test.go @@ -148,12 +148,12 @@ func TestListPolicyVersions_PagesNewestFirst(t *testing.T) { return got } - first, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2)}, svc) + first, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), MaxItems: aws.Int64(2)}, svc) require.NoError(t, err) assert.Equal(t, []string{"v10", "v9"}, ids(first)) assert.True(t, aws.BoolValue(first.IsTruncated)) - second, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2), Marker: first.Marker}, svc) + second, err := gateway_iam.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), MaxItems: aws.Int64(2), Marker: first.Marker}, svc) require.NoError(t, err) assert.Equal(t, []string{"v2"}, ids(second)) assert.False(t, aws.BoolValue(second.IsTruncated)) @@ -169,7 +169,7 @@ func TestListEntitiesForPolicy_PagesAcrossAllThreeLists(t *testing.T) { PolicyGroups: []*iam.PolicyGroup{{GroupName: aws.String("g1")}}, } }} - input := &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2)} + input := &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), MaxItems: aws.Int64(2)} first, err := gateway_iam.ListEntitiesForPolicy(testAccountID, input, svc) require.NoError(t, err) diff --git a/spinifex/gateway/iam/pathprefix.go b/spinifex/gateway/iam/pathprefix.go new file mode 100644 index 000000000..92efe4c06 --- /dev/null +++ b/spinifex/gateway/iam/pathprefix.go @@ -0,0 +1,34 @@ +package gateway_iam + +import ( + "regexp" + + "github.com/mulgadc/spinifex/spinifex/awserrors" +) + +const maxPathPrefixLength = 512 + +// The three PathPrefix shapes in the IAM model: identity lists, policy lists +// (a whole path, trailing slash included), and ListEntitiesForPolicy. +var ( + identityPathPrefix = regexp.MustCompile(`^/[\x21-\x7F]*$`) + policyPathPrefix = regexp.MustCompile(`^(/[A-Za-z0-9.,+@=_-]+)*/$`) + entityPathPrefix = regexp.MustCompile(`^(/|/[\x21-\x7E]+/)$`) +) + +// validatePathPrefix checks an optional PathPrefix as AWS does: the pattern +// first, with IAM's own message, then the model's length limit. +func validatePathPrefix(prefix *string, pattern *regexp.Regexp) error { + if prefix == nil { + return nil + } + if !pattern.MatchString(*prefix) { + return awserrors.Errorf(awserrors.ErrorValidationError, + "The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters.") + } + if len(*prefix) > maxPathPrefixLength { + return awserrors.Errorf(awserrors.ErrorValidationError, + "1 validation error detected: Value at 'pathPrefix' failed to satisfy constraint: Member must have length less than or equal to %d", maxPathPrefixLength) + } + return nil +} diff --git a/spinifex/gateway/iam/pathprefix_test.go b/spinifex/gateway/iam/pathprefix_test.go new file mode 100644 index 000000000..b1d6fdbc7 --- /dev/null +++ b/spinifex/gateway/iam/pathprefix_test.go @@ -0,0 +1,118 @@ +package gateway_iam_test + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" + gateway_iam "github.com/mulgadc/spinifex/spinifex/gateway/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The messages are the ones AWS returned for the same PathPrefix. +const ( + badPathPrefix = "The specified value for pathPrefix is invalid. It must begin with the / character and contain only alphanumeric characters and/or / characters." + longPathPrefix = "1 validation error detected: Value at 'pathPrefix' failed to satisfy constraint: Member must have length less than or equal to 512" +) + +// Each list is refused before the service runs; the stub panics if called. +func TestListCalls_RejectPathPrefixBeforeListing(t *testing.T) { + lists := map[string]func(prefix string) error{ + "ListUsers": func(p string) error { + _, err := gateway_iam.ListUsers(testAccountID, &iam.ListUsersInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListRoles": func(p string) error { + _, err := gateway_iam.ListRoles(testAccountID, &iam.ListRolesInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListGroups": func(p string) error { + _, err := gateway_iam.ListGroups(testAccountID, &iam.ListGroupsInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListInstanceProfiles": func(p string) error { + _, err := gateway_iam.ListInstanceProfiles(testAccountID, &iam.ListInstanceProfilesInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListPolicies": func(p string) error { + _, err := gateway_iam.ListPolicies(testAccountID, &iam.ListPoliciesInput{PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListEntitiesForPolicy": func(p string) error { + _, err := gateway_iam.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{ + PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), PathPrefix: aws.String(p), + }, &listStub{}) + return err + }, + "ListAttachedUserPolicies": func(p string) error { + _, err := gateway_iam.ListAttachedUserPolicies(testAccountID, &iam.ListAttachedUserPoliciesInput{UserName: aws.String("u"), PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListAttachedRolePolicies": func(p string) error { + _, err := gateway_iam.ListAttachedRolePolicies(testAccountID, &iam.ListAttachedRolePoliciesInput{RoleName: aws.String("r"), PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + "ListAttachedGroupPolicies": func(p string) error { + _, err := gateway_iam.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{GroupName: aws.String("g"), PathPrefix: aws.String(p)}, &listStub{}) + return err + }, + } + cases := []struct { + name string + prefix string + wantMsg string + }{ + {"empty", "", badPathPrefix}, + {"no leading slash", "awsdiff", badPathPrefix}, + {"space", "/bad path/", badPathPrefix}, + {"over 512 characters", "/" + strings.Repeat("a", 511) + "/", longPathPrefix}, + } + for op, list := range lists { + for _, tc := range cases { + t.Run(op+"/"+tc.name, func(t *testing.T) { + err := list(tc.prefix) + require.Error(t, err) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, awserrors.ErrorValidationError, code) + assert.Equal(t, tc.wantMsg, msg) + }) + } + } +} + +func TestListUsers_PathPrefixAccepted(t *testing.T) { + for _, prefix := range []string{"/", "/~/", "/team", "/" + strings.Repeat("a", 511)} { + svc := &listStub{users: []string{"a"}} + _, err := gateway_iam.ListUsers(testAccountID, &iam.ListUsersInput{PathPrefix: aws.String(prefix)}, svc) + require.NoError(t, err, prefix) + assert.Equal(t, 1, svc.calls) + } +} + +// Policy paths end in a slash, so a prefix without one breaks the pattern +// before its length is checked. +func TestListPolicies_PathPrefixPatternBeforeLength(t *testing.T) { + _, err := gateway_iam.ListPolicies(testAccountID, &iam.ListPoliciesInput{PathPrefix: aws.String("/" + strings.Repeat("a", 512))}, &listStub{}) + _, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, badPathPrefix, msg) +} + +// The messages are the ones AWS returned for a 19- and a 2049-character ARN. +func TestListEntitiesForPolicy_PolicyArnLength(t *testing.T) { + cases := map[string]string{ + "arn:aws:iam::aws:x": "1 validation error detected: Value at 'policyArn' failed to satisfy constraint: Member must have length greater than or equal to 20", + "arn:aws:iam::aws:policy/" + strings.Repeat("p", 2025): "1 validation error detected: Value at 'policyArn' failed to satisfy constraint: Member must have length less than or equal to 2048", + } + for arn, want := range cases { + _, err := gateway_iam.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String(arn)}, &listStub{}) + code, msg, ok := awserrors.ResolveErrorDetail(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, awserrors.ErrorValidationError, code) + assert.Equal(t, want, msg) + } +} diff --git a/spinifex/gateway/iam/policy.go b/spinifex/gateway/iam/policy.go index 0fd31f2c2..e226e0ce7 100644 --- a/spinifex/gateway/iam/policy.go +++ b/spinifex/gateway/iam/policy.go @@ -2,6 +2,7 @@ package gateway_iam import ( "errors" + "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -9,6 +10,12 @@ import ( handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" ) +// The model's arnType length bounds. +const ( + minARNLength = 20 + maxARNLength = 2048 +) + func CreatePolicy(accountID string, input *iam.CreatePolicyInput, svc handlers_iam.IAMService) (*iam.CreatePolicyOutput, error) { if input.PolicyName == nil || *input.PolicyName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) @@ -91,6 +98,9 @@ func DeletePolicyVersion(accountID string, input *iam.DeletePolicyVersionInput, } func ListPolicies(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (*iam.ListPoliciesOutput, error) { + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -115,6 +125,17 @@ func ListEntitiesForPolicy(accountID string, input *iam.ListEntitiesForPolicyInp if input.PolicyArn == nil || *input.PolicyArn == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if n := len(*input.PolicyArn); n < minARNLength || n > maxARNLength { + bound := fmt.Sprintf("greater than or equal to %d", minARNLength) + if n > maxARNLength { + bound = fmt.Sprintf("less than or equal to %d", maxARNLength) + } + return nil, awserrors.Errorf(awserrors.ErrorValidationError, + "1 validation error detected: Value at 'policyArn' failed to satisfy constraint: Member must have length %s", bound) + } + if err := validatePathPrefix(input.PathPrefix, entityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -187,6 +208,9 @@ func ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPolic if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/role.go b/spinifex/gateway/iam/role.go index e7826eb41..77f522236 100644 --- a/spinifex/gateway/iam/role.go +++ b/spinifex/gateway/iam/role.go @@ -37,6 +37,9 @@ func GetRole(accountID string, input *iam.GetRoleInput, svc handlers_iam.IAMServ } func ListRoles(accountID string, input *iam.ListRolesInput, svc handlers_iam.IAMService) (*iam.ListRolesOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err @@ -99,6 +102,9 @@ func ListAttachedRolePolicies(accountID string, input *iam.ListAttachedRolePolic if input.RoleName == nil || *input.RoleName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + if err := validatePathPrefix(input.PathPrefix, policyPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/gateway/iam/user.go b/spinifex/gateway/iam/user.go index 1d96ae7db..799962d86 100644 --- a/spinifex/gateway/iam/user.go +++ b/spinifex/gateway/iam/user.go @@ -24,6 +24,9 @@ func GetUser(accountID string, input *iam.GetUserInput, svc handlers_iam.IAMServ } func ListUsers(accountID string, input *iam.ListUsersInput, svc handlers_iam.IAMService) (*iam.ListUsersOutput, error) { + if err := validatePathPrefix(input.PathPrefix, identityPathPrefix); err != nil { + return nil, err + } p, err := newPager(input.Marker, input.MaxItems) if err != nil { return nil, err diff --git a/spinifex/handlers/iam/groups.go b/spinifex/handlers/iam/groups.go index a08be96b9..69ba1a68a 100644 --- a/spinifex/handlers/iam/groups.go +++ b/spinifex/handlers/iam/groups.go @@ -378,25 +378,7 @@ func (s *IAMServiceImpl) ListAttachedGroupPolicies(accountID string, input *iam. return nil, err } - var attached []*iam.AttachedPolicy - for _, arn := range group.AttachedPolicies { - if isAWSManagedPolicyARN(arn) { - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(arn), - PolicyName: aws.String(managedPolicyNameFromARN(arn)), - }) - continue - } - policy, err := s.getPolicyByARN(ctx, accountID, arn) - if err != nil { - slog.Warn("ListAttachedGroupPolicies: policy not found for ARN", "arn", arn, "err", err) - continue - } - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(policy.ARN), - PolicyName: aws.String(policy.PolicyName), - }) - } + attached := s.attachedPolicies(ctx, accountID, group.AttachedPolicies, aws.StringValue(input.PathPrefix)) return &iam.ListAttachedGroupPoliciesOutput{ AttachedPolicies: attached, diff --git a/spinifex/handlers/iam/groups_test.go b/spinifex/handlers/iam/groups_test.go index cd0d8a652..3ff901a24 100644 --- a/spinifex/handlers/iam/groups_test.go +++ b/spinifex/handlers/iam/groups_test.go @@ -83,7 +83,7 @@ func TestCreateGroup_InvalidName(t *testing.T) { GroupName: aws.String(longName), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateGroup_InvalidPath(t *testing.T) { diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go index dad88ef5a..0f69a5200 100644 --- a/spinifex/handlers/iam/instance_profiles.go +++ b/spinifex/handlers/iam/instance_profiles.go @@ -41,6 +41,10 @@ func (s *IAMServiceImpl) CreateInstanceProfile(accountID string, input *iam.Crea } } + if err := validateTags(input.Tags, exactKeys); err != nil { + return nil, err + } + profileID, err := generateIAMID("AIPA") if err != nil { return nil, fmt.Errorf("generate instance profile ID: %w", err) @@ -288,13 +292,13 @@ func parseInstanceProfileARN(arnStr string) (accountID, name string, err error) // RoleName and silently undo a concurrent role attach. func (s *IAMServiceImpl) TagInstanceProfile(accountID string, input *iam.TagInstanceProfileInput) (*iam.TagInstanceProfileOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } profileName := *input.InstanceProfileName err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) { - merged := mergeTags(p.Tags, input.Tags) + merged := mergeTags(p.Tags, input.Tags, exactKeys) if len(merged) > maxTagsPerResource { return false, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -312,10 +316,13 @@ func (s *IAMServiceImpl) TagInstanceProfile(accountID string, input *iam.TagInst // UntagInstanceProfile removes the named tag keys from an instance profile; // unknown keys are a no-op. func (s *IAMServiceImpl) UntagInstanceProfile(accountID string, input *iam.UntagInstanceProfileInput) (*iam.UntagInstanceProfileOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() profileName := *input.InstanceProfileName err := s.updateInstanceProfileCAS(ctx, accountID, profileName, func(p *InstanceProfile) (bool, error) { - kept := removeTagKeys(p.Tags, input.TagKeys) + kept := removeTagKeys(p.Tags, input.TagKeys, exactKeys) if len(kept) == len(p.Tags) { return false, nil } diff --git a/spinifex/handlers/iam/instance_profiles_test.go b/spinifex/handlers/iam/instance_profiles_test.go index dd03f18d4..367d81673 100644 --- a/spinifex/handlers/iam/instance_profiles_test.go +++ b/spinifex/handlers/iam/instance_profiles_test.go @@ -70,7 +70,7 @@ func TestCreateInstanceProfile_InvalidName(t *testing.T) { InstanceProfileName: aws.String(longName), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateInstanceProfile_InvalidPath(t *testing.T) { diff --git a/spinifex/handlers/iam/oidc_providers.go b/spinifex/handlers/iam/oidc_providers.go index 8d8cb809f..203cf1aa7 100644 --- a/spinifex/handlers/iam/oidc_providers.go +++ b/spinifex/handlers/iam/oidc_providers.go @@ -9,8 +9,10 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strings" "time" + "unicode/utf8" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -121,14 +123,43 @@ func awsStrings(in []*string) []string { return out } +// validateOIDCProviderLists checks every client ID and thumbprint against the +// model's lengths, before the Url as AWS orders them. AWS names each list once, +// however many of its entries break. +func validateOIDCProviderLists(clientIDs, thumbprints []*string) error { + outside := func(minLen, maxLen int) func(*string) bool { + return func(v *string) bool { + n := utf8.RuneCountInString(aws.StringValue(v)) + return v == nil || n < minLen || n > maxLen + } + } + var violations []string + if slices.ContainsFunc(clientIDs, outside(1, 255)) { + violations = append(violations, "Value at 'clientIDList' failed to satisfy constraint: Member must satisfy constraint: "+ + "[Member must have length less than or equal to 255, Member must have length greater than or equal to 1]") + } + if slices.ContainsFunc(thumbprints, outside(40, 40)) { + violations = append(violations, "Value at 'thumbprintList' failed to satisfy constraint: Member must satisfy constraint: "+ + "[Member must have length less than or equal to 40, Member must have length greater than or equal to 40]") + } + return validationError(violations) +} + func (s *IAMServiceImpl) CreateOpenIDConnectProvider(accountID string, input *iam.CreateOpenIDConnectProviderInput) (*iam.CreateOpenIDConnectProviderOutput, error) { ctx := context.Background() + if err := validateOIDCProviderLists(input.ClientIDList, input.ThumbprintList); err != nil { + return nil, err + } issuer := aws.StringValue(input.Url) if err := validateOIDCProviderURL(issuer); err != nil { slog.Debug("CreateOpenIDConnectProvider: invalid Url", "url", issuer, "err", err) return nil, awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "The specified value for url is invalid: %v", err) } + if err := validateTags(input.Tags, exactKeys); err != nil { + return nil, err + } + record := OIDCProviderRecord{ Url: issuer, ClientIDList: awsStrings(input.ClientIDList), @@ -262,7 +293,7 @@ func (s *IAMServiceImpl) DeleteOpenIDConnectProvider(accountID string, input *ia // read-modify-write Put like the other writers here (no CAS). func (s *IAMServiceImpl) TagOpenIDConnectProvider(accountID string, input *iam.TagOpenIDConnectProviderInput) (*iam.TagOpenIDConnectProviderOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } @@ -272,7 +303,7 @@ func (s *IAMServiceImpl) TagOpenIDConnectProvider(accountID string, input *iam.T return nil, err } - merged := mergeTags(record.Tags, input.Tags) + merged := mergeTags(record.Tags, input.Tags, exactKeys) if len(merged) > maxTagsPerResource { return nil, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -289,6 +320,9 @@ func (s *IAMServiceImpl) TagOpenIDConnectProvider(accountID string, input *iam.T // UntagOpenIDConnectProvider removes the named tag keys from an OIDC provider; // unknown keys are a no-op. func (s *IAMServiceImpl) UntagOpenIDConnectProvider(accountID string, input *iam.UntagOpenIDConnectProviderInput) (*iam.UntagOpenIDConnectProviderOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() arn := aws.StringValue(input.OpenIDConnectProviderArn) record, err := s.getOIDCProvider(ctx, accountID, arn) @@ -296,7 +330,7 @@ func (s *IAMServiceImpl) UntagOpenIDConnectProvider(accountID string, input *iam return nil, err } - record.Tags = removeTagKeys(record.Tags, input.TagKeys) + record.Tags = removeTagKeys(record.Tags, input.TagKeys, exactKeys) if err := s.putOIDCProvider(ctx, accountID, record); err != nil { return nil, err diff --git a/spinifex/handlers/iam/oidc_providers_test.go b/spinifex/handlers/iam/oidc_providers_test.go index dd89cd09e..6c96c0acf 100644 --- a/spinifex/handlers/iam/oidc_providers_test.go +++ b/spinifex/handlers/iam/oidc_providers_test.go @@ -214,3 +214,44 @@ func TestIssuerProviderARNRoundTrip(t *testing.T) { t.Fatal("round-trip key mismatch") } } + +// The messages are the ones AWS returned; the lists are checked before the Url. +func TestCreateOpenIDConnectProvider_ListEntryLengths(t *testing.T) { + t.Parallel() + const ( + clientIDMsg = "1 validation error detected: Value at 'clientIDList' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 255, Member must have length greater than or equal to 1]" + thumbprintMsg = "1 validation error detected: Value at 'thumbprintList' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 40, Member must have length greater than or equal to 40]" + ) + thumbprint := strings.Repeat("9", 40) + cases := []struct { + name string + clientIDs []string + thumbprints []string + wantMsg string + }{ + {"empty client ID", []string{"sts.amazonaws.com", ""}, []string{thumbprint}, clientIDMsg}, + {"256-character client ID", []string{strings.Repeat("c", 256)}, []string{thumbprint}, clientIDMsg}, + {"39-character thumbprint", []string{"sts.amazonaws.com"}, []string{strings.Repeat("9", 39)}, thumbprintMsg}, + {"41-character thumbprint", []string{"sts.amazonaws.com"}, []string{thumbprint, strings.Repeat("9", 41)}, thumbprintMsg}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + _, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("http://not-https.example.com"), ClientIDList: aws.StringSlice(tc.clientIDs), ThumbprintList: aws.StringSlice(tc.thumbprints), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + }) + } + + svc := setupTestIAMService(t) + _, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://oidc.example.com/id/BOUNDS"), + ClientIDList: aws.StringSlice([]string{"c", strings.Repeat("c", 255)}), + ThumbprintList: aws.StringSlice([]string{thumbprint}), + }) + if err != nil { + t.Fatalf("entries at the length bounds: %v", err) + } +} diff --git a/spinifex/handlers/iam/roles.go b/spinifex/handlers/iam/roles.go index 8e0355fc2..3aa3c328c 100644 --- a/spinifex/handlers/iam/roles.go +++ b/spinifex/handlers/iam/roles.go @@ -28,7 +28,7 @@ const ( maxTrustPolicyDocumentSize = 2048 defaultMaxSessionDuration = int64(3600) - minMaxSessionDuration = int64(900) + minMaxSessionDuration = int64(3600) maxMaxSessionDuration = int64(43200) // Bound on optimistic-concurrency retries when a concurrent writer wins the @@ -56,6 +56,13 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput } } + if err := validateDescription(input.Description); err != nil { + return nil, err + } + if err := validateMaxSessionDuration(input.MaxSessionDuration); err != nil { + return nil, err + } + // Carry the reason: a bare code cannot tell a caller which statement or key // was refused, and the gateway logs the returned error, so one message // serves both the client and the operator. @@ -67,9 +74,10 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput maxSession := defaultMaxSessionDuration if input.MaxSessionDuration != nil { maxSession = *input.MaxSessionDuration - if maxSession < minMaxSessionDuration || maxSession > maxMaxSessionDuration { - return nil, errors.New(awserrors.ErrorValidationError) - } + } + + if err := validateTags(input.Tags, foldedKeys); err != nil { + return nil, err } roleID, err := generateIAMID("AROA") @@ -211,9 +219,29 @@ func (s *IAMServiceImpl) DeleteRole(accountID string, input *iam.DeleteRoleInput return &iam.DeleteRoleOutput{}, nil } +// validateMaxSessionDuration enforces the role session limit of one to twelve +// hours, in seconds, with the ValidationError AWS returns. +func validateMaxSessionDuration(d *int64) error { + switch { + case d == nil: + return nil + case *d < minMaxSessionDuration: + return validationError([]string{fmt.Sprintf("Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to %d", minMaxSessionDuration)}) + case *d > maxMaxSessionDuration: + return validationError([]string{fmt.Sprintf("Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to %d", maxMaxSessionDuration)}) + } + return nil +} + func (s *IAMServiceImpl) UpdateRole(accountID string, input *iam.UpdateRoleInput) (*iam.UpdateRoleOutput, error) { ctx := context.Background() roleName := *input.RoleName + if err := validateDescription(input.Description); err != nil { + return nil, err + } + if err := validateMaxSessionDuration(input.MaxSessionDuration); err != nil { + return nil, err + } role, err := s.getRole(ctx, accountID, roleName) if err != nil { @@ -224,11 +252,7 @@ func (s *IAMServiceImpl) UpdateRole(accountID string, input *iam.UpdateRoleInput role.Description = *input.Description } if input.MaxSessionDuration != nil { - dur := *input.MaxSessionDuration - if dur < minMaxSessionDuration || dur > maxMaxSessionDuration { - return nil, errors.New(awserrors.ErrorValidationError) - } - role.MaxSessionDuration = dur + role.MaxSessionDuration = *input.MaxSessionDuration } data, err := json.Marshal(role) @@ -330,25 +354,7 @@ func (s *IAMServiceImpl) ListAttachedRolePolicies(accountID string, input *iam.L return nil, err } - var attached []*iam.AttachedPolicy - for _, arn := range role.AttachedPolicies { - if isAWSManagedPolicyARN(arn) { - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(arn), - PolicyName: aws.String(managedPolicyNameFromARN(arn)), - }) - continue - } - policy, err := s.getPolicyByARN(ctx, accountID, arn) - if err != nil { - slog.Warn("ListAttachedRolePolicies: policy not found for ARN", "arn", arn, "err", err) - continue - } - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(policy.ARN), - PolicyName: aws.String(policy.PolicyName), - }) - } + attached := s.attachedPolicies(ctx, accountID, role.AttachedPolicies, aws.StringValue(input.PathPrefix)) return &iam.ListAttachedRolePoliciesOutput{ AttachedPolicies: attached, @@ -466,13 +472,13 @@ func (s *IAMServiceImpl) ListRolePolicies(accountID string, input *iam.ListRoleP // TagRole upserts tags on a role under CAS, like the other role writers. func (s *IAMServiceImpl) TagRole(accountID string, input *iam.TagRoleInput) (*iam.TagRoleOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, foldedKeys); err != nil { return nil, err } roleName := *input.RoleName err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) { - merged := mergeTags(role.Tags, input.Tags) + merged := mergeTags(role.Tags, input.Tags, foldedKeys) if len(merged) > maxTagsPerResource { return false, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -489,10 +495,13 @@ func (s *IAMServiceImpl) TagRole(accountID string, input *iam.TagRoleInput) (*ia // UntagRole removes the named tag keys from a role; unknown keys are a no-op. func (s *IAMServiceImpl) UntagRole(accountID string, input *iam.UntagRoleInput) (*iam.UntagRoleOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() roleName := *input.RoleName err := s.updateRoleCAS(ctx, accountID, roleName, func(role *Role) (bool, error) { - role.Tags = removeTagKeys(role.Tags, input.TagKeys) + role.Tags = removeTagKeys(role.Tags, input.TagKeys, foldedKeys) return true, nil }) if err != nil { diff --git a/spinifex/handlers/iam/roles_test.go b/spinifex/handlers/iam/roles_test.go index 11aa9dc1a..5c62fc536 100644 --- a/spinifex/handlers/iam/roles_test.go +++ b/spinifex/handlers/iam/roles_test.go @@ -3,6 +3,7 @@ package handlers_iam import ( "encoding/json" "slices" + "strconv" "strings" "sync" "testing" @@ -102,7 +103,7 @@ func TestCreateRole_InvalidName(t *testing.T) { AssumeRolePolicyDocument: aws.String(validTrustPolicy()), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateRole_InvalidPath(t *testing.T) { @@ -320,30 +321,45 @@ func TestCreateRole_TrustPolicy_DenyEffectAccepted(t *testing.T) { require.NoError(t, err) } -func TestCreateRole_MaxSessionDuration_TooSmall(t *testing.T) { +// The bounds and messages are the ones AWS returned; both calls check the +// value before the trust policy or the role lookup. +func TestRole_MaxSessionDurationBounds(t *testing.T) { t.Parallel() - svc := setupTestIAMService(t) - - _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ - RoleName: aws.String("short-session"), - AssumeRolePolicyDocument: aws.String(validTrustPolicy()), - MaxSessionDuration: aws.Int64(minMaxSessionDuration - 1), - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorValidationError) -} - -func TestCreateRole_MaxSessionDuration_TooLarge(t *testing.T) { - t.Parallel() - svc := setupTestIAMService(t) - - _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ - RoleName: aws.String("long-session"), - AssumeRolePolicyDocument: aws.String(validTrustPolicy()), - MaxSessionDuration: aws.Int64(maxMaxSessionDuration + 1), - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorValidationError) + const ( + tooShort = "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value greater than or equal to 3600" + tooLong = "1 validation error detected: Value at 'maxSessionDuration' failed to satisfy constraint: Member must have value less than or equal to 43200" + ) + cases := []struct { + seconds int64 + wantMsg string + }{ + {900, tooShort}, + {3599, tooShort}, + {3600, ""}, + {43200, ""}, + {43201, tooLong}, + } + for _, tc := range cases { + t.Run(strconv.FormatInt(tc.seconds, 10), func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + if tc.wantMsg != "" { + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String("{"), MaxSessionDuration: aws.Int64(tc.seconds), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("missing"), MaxSessionDuration: aws.Int64(tc.seconds)}) + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + return + } + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()), MaxSessionDuration: aws.Int64(tc.seconds), + }) + require.NoError(t, err) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("r"), MaxSessionDuration: aws.Int64(tc.seconds)}) + require.NoError(t, err) + }) + } } func TestCreateRole_Duplicate(t *testing.T) { @@ -541,19 +557,6 @@ func TestUpdateRole_NotFound(t *testing.T) { assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) } -func TestUpdateRole_InvalidMaxSessionDuration(t *testing.T) { - t.Parallel() - svc := setupTestIAMService(t) - createTestRole(t, svc, "session-role") - - _, err := svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{ - RoleName: aws.String("session-role"), - MaxSessionDuration: aws.Int64(60), - }) - assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorValidationError) -} - func TestUpdateAssumeRolePolicy(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) @@ -1597,3 +1600,35 @@ func TestUpdateAssumeRolePolicy_NotActionRejected(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument) } + +// The message is the one AWS returned for a 1001-character description, which +// it checks before the role lookup and the trust policy. +func TestRoleAndPolicy_DescriptionLength(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + const msg = "1 validation error detected: Value at 'description' failed to satisfy constraint: Member must have length less than or equal to 1000" + tooLong := aws.String(strings.Repeat("é", maxDescriptionLength+1)) + + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String("{"), Description: tooLong, + }) + requireIAMError(t, err, awserrors.ErrorValidationError, msg) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("missing"), Description: tooLong}) + requireIAMError(t, err, awserrors.ErrorValidationError, msg) + _, err = svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("p"), PolicyDocument: aws.String("{"), Description: tooLong, + }) + requireIAMError(t, err, awserrors.ErrorValidationError, msg) + + atMax := aws.String(strings.Repeat("é", maxDescriptionLength)) + _, err = svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("r"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()), Description: atMax, + }) + require.NoError(t, err) + _, err = svc.UpdateRole(testAccountID, &iam.UpdateRoleInput{RoleName: aws.String("r"), Description: atMax}) + require.NoError(t, err) + _, err = svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("p"), PolicyDocument: aws.String(validPolicyDocument()), Description: atMax, + }) + require.NoError(t, err) +} diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 3e1210b0b..0302ecfee 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -10,10 +10,12 @@ import ( "log/slog" "maps" "net/netip" + "regexp" "slices" "strconv" "strings" "time" + "unicode/utf8" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" @@ -54,6 +56,7 @@ const ( maxTagsPerResource = 50 maxTagKeyLength = 128 maxTagValueLength = 256 + maxDescriptionLength = 1000 // LongLivedAccessKeyIDPrefix is the AWS-defined prefix for long-lived IAM access keys. // The access-keys bucket rejects writes with any other prefix to prevent silent privilege escalation. @@ -218,14 +221,12 @@ func NewIAMServiceImpl(ctx context.Context, natsConn *nats.Conn, masterKey []byt }, nil } -// copyTags converts SDK IAM tags into the stored Tag slice, skipping entries -// with a nil key or value. Returns a non-nil (possibly empty) slice. +// copyTags converts SDK IAM tags, already checked by validateTags, into the +// stored Tag slice. Returns a non-nil (possibly empty) slice. func copyTags(tags []*iam.Tag) []Tag { out := make([]Tag, 0, len(tags)) for _, tag := range tags { - if tag.Key != nil && tag.Value != nil { - out = append(out, Tag{Key: *tag.Key, Value: *tag.Value}) - } + out = append(out, Tag{Key: *tag.Key, Value: *tag.Value}) } return out } @@ -242,29 +243,57 @@ func tagsToSDK(tags []Tag) []*iam.Tag { return out } -// validateTags enforces the AWS IAM tag limits on request input: at most 50 -// tags, key length 1-128, value length 0-256, no duplicate keys. -func validateTags(tags []*iam.Tag) error { - if len(tags) > maxTagsPerResource { - return errors.New(awserrors.ErrorIAMLimitExceeded) +// Tag key and value patterns from the IAM model, as AWS enforces them. +var ( + tagKeyPattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$`) + tagValuePattern = regexp.MustCompile(`^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$`) +) + +// keyCase is how a resource type compares tag keys: AWS ignores case on users +// and roles, and matches exactly on policies, instance profiles and OIDC +// providers. +type keyCase bool + +const ( + exactKeys keyCase = false + foldedKeys keyCase = true +) + +func (c keyCase) norm(key string) string { + if c == foldedKeys { + return strings.ToLower(key) + } + return key +} + +// validateTags enforces the AWS IAM tag limits on request input: the model's +// list length and its null, length (in characters) and pattern constraints on +// every key and value, then no reserved aws: prefix and no duplicate keys as +// keys compares them. +func validateTags(tags []*iam.Tag, keys keyCase) error { + var violations []string + if len(tags) > maxTagsPerResource { + violations = append(violations, fmt.Sprintf("Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to %d", maxTagsPerResource)) } - seen := make(map[string]struct{}, len(tags)) for i, tag := range tags { field := fmt.Sprintf("tags.%d.member", i+1) - if tag == nil || tag.Key == nil { - return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, - "1 validation error detected: Value null at '%s.key' failed to satisfy constraint: Member must not be null", field) + if tag == nil { + tag = &iam.Tag{} } - key := *tag.Key - if len(key) < 1 { - return lengthViolation(key, field+".key", "greater than or equal to 1") - } - if len(key) > maxTagKeyLength { - return lengthViolation(key, field+".key", fmt.Sprintf("less than or equal to %d", maxTagKeyLength)) - } - if tag.Value != nil && len(*tag.Value) > maxTagValueLength { - return lengthViolation(*tag.Value, field+".value", fmt.Sprintf("less than or equal to %d", maxTagValueLength)) + violations = append(violations, tagMemberViolations(field+".key", tag.Key, 1, maxTagKeyLength, tagKeyPattern)...) + violations = append(violations, tagMemberViolations(field+".value", tag.Value, 0, maxTagValueLength, tagValuePattern)...) + } + if err := validationError(violations); err != nil { + return err + } + for _, tag := range tags { + if strings.HasPrefix(strings.ToLower(*tag.Key), "aws:") { + return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "Tag keys beginning with aws: are reserved for system use.") } + } + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + key := keys.norm(*tag.Key) if _, dup := seen[key]; dup { return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, "Duplicate tag keys found. Please note that Tag keys are case insensitive.") @@ -274,16 +303,73 @@ func validateTags(tags []*iam.Tag) error { return nil } +// validateTagKeys enforces the model constraints on an untag request's key +// list. AWS reports every bad key as one violation of the list. +func validateTagKeys(keys []*string) error { + var violations []string + if len(keys) > maxTagsPerResource { + violations = append(violations, fmt.Sprintf("Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to %d", maxTagsPerResource)) + } + for _, key := range keys { + if key == nil || len(tagMemberViolations("", key, 1, maxTagKeyLength, tagKeyPattern)) > 0 { + violations = append(violations, fmt.Sprintf("Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: "+ + "[Member must have length less than or equal to %d, Member must have length greater than or equal to 1, "+ + "Member must satisfy regular expression pattern: %s, Member must not be null]", maxTagKeyLength, modelPattern(tagKeyPattern))) + break + } + } + return validationError(violations) +} + +// validationError renders model-constraint violations as the single +// ValidationError AWS returns for them, or nil when there are none. +func validationError(violations []string) error { + if len(violations) == 0 { + return nil + } + noun := "errors" + if len(violations) == 1 { + noun = "error" + } + return awserrors.Errorf(awserrors.ErrorValidationError, + "%d validation %s detected: %s", len(violations), noun, strings.Join(violations, "; ")) +} + +// tagMemberViolations checks one tag key or value. AWS leaves the value out of +// tag violation messages, so these name only the member. +func tagMemberViolations(field string, value *string, minLen, maxLen int, pattern *regexp.Regexp) []string { + prefix := "Value at '" + field + "' failed to satisfy constraint: Member must " + if value == nil { + return []string{prefix + "not be null"} + } + var out []string + if n := utf8.RuneCountInString(*value); n < minLen { + out = append(out, fmt.Sprintf("%shave length greater than or equal to %d", prefix, minLen)) + } else if n > maxLen { + out = append(out, fmt.Sprintf("%shave length less than or equal to %d", prefix, maxLen)) + } + if !pattern.MatchString(*value) { + out = append(out, prefix+"satisfy regular expression pattern: "+modelPattern(pattern)) + } + return out +} + +// modelPattern renders a compiled tag pattern as the model writes it. +func modelPattern(pattern *regexp.Regexp) string { + return strings.TrimSuffix(strings.TrimPrefix(pattern.String(), "^"), "$") +} + // mergeTags upserts add into existing by key, matching AWS semantics: a -// repeated key overwrites in place, new keys append in input order. -func mergeTags(existing []Tag, add []*iam.Tag) []Tag { +// repeated key, as keys compares them, is overwritten in place with the new +// key's case; new keys append in input order. +func mergeTags(existing []Tag, add []*iam.Tag, keys keyCase) []Tag { out := slices.Clone(existing) for _, tag := range add { if tag.Key == nil { continue } next := Tag{Key: *tag.Key, Value: aws.StringValue(tag.Value)} - idx := slices.IndexFunc(out, func(t Tag) bool { return t.Key == next.Key }) + idx := slices.IndexFunc(out, func(t Tag) bool { return keys.norm(t.Key) == keys.norm(next.Key) }) if idx >= 0 { out[idx] = next } else { @@ -293,18 +379,18 @@ func mergeTags(existing []Tag, add []*iam.Tag) []Tag { return out } -// removeTagKeys drops the named keys from existing; unknown keys are -// silently ignored, matching AWS. -func removeTagKeys(existing []Tag, keys []*string) []Tag { +// removeTagKeys drops the named keys from existing, as match compares them; +// unknown keys are silently ignored, matching AWS. +func removeTagKeys(existing []Tag, keys []*string, match keyCase) []Tag { drop := make(map[string]struct{}, len(keys)) for _, k := range keys { if k != nil { - drop[*k] = struct{}{} + drop[match.norm(*k)] = struct{}{} } } out := make([]Tag, 0, len(existing)) for _, t := range existing { - if _, gone := drop[t.Key]; !gone { + if _, gone := drop[match.norm(t.Key)]; !gone { out = append(out, t) } } @@ -334,6 +420,10 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput } } + if err := validateTags(input.Tags, foldedKeys); err != nil { + return nil, err + } + userID, err := generateIAMID("AIDA") if err != nil { return nil, fmt.Errorf("generate user ID: %w", err) @@ -1185,19 +1275,23 @@ func (s *IAMServiceImpl) CreatePolicy(accountID string, input *iam.CreatePolicyI kvKey := accountID + "." + policyName + if err := validateDescription(input.Description); err != nil { + return nil, err + } + path := "/" + if input.Path != nil { + path = *input.Path + if err := validatePath(path); err != nil { + return nil, err + } + } + if _, err := ValidatePolicyDocument(*input.PolicyDocument); err != nil { return nil, awserrors.Errorf(awserrors.ErrorIAMMalformedPolicyDocument, "policy %q: %w", policyName, err) } - if err := validateTags(input.Tags); err != nil { - return nil, err - } - - path := aws.StringValue(input.Path) - if path == "" { - path = "/" - } else if err := validatePath(path); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } @@ -1257,6 +1351,12 @@ func (s *IAMServiceImpl) GetPolicy(accountID string, input *iam.GetPolicyInput) func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { ctx := context.Background() + if err := checkEnum("scope", input.Scope, policyScopeValues); err != nil { + return nil, err + } + if err := checkEnum("policyUsageFilter", input.PolicyUsageFilter, policyUsageValues); err != nil { + return nil, err + } keys, err := kvutil.Keys(ctx, s.policiesBucket) if err != nil { if errors.Is(err, jetstream.ErrNoKeysFound) { @@ -1405,6 +1505,9 @@ func (s *IAMServiceImpl) ListEntitiesForPolicy(accountID string, input *iam.List IsTruncated: aws.Bool(false), } + if err := checkEnum("policyUsageFilter", input.PolicyUsageFilter, policyUsageValues); err != nil { + return nil, err + } // Permissions boundaries are rejected outright at policy-attachment time, // so no entity ever uses a policy that way. The correct answer is empty. if aws.StringValue(input.PolicyUsageFilter) == iam.PolicyUsageTypePermissionsBoundary { @@ -1531,27 +1634,28 @@ func (s *IAMServiceImpl) DetachUserPolicy(accountID string, input *iam.DetachUse return &iam.DetachUserPolicyOutput{}, nil } -func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) { - ctx := context.Background() - user, err := s.getUser(ctx, accountID, *input.UserName) - if err != nil { - return nil, err - } - +// attachedPolicies resolves an identity's attached policy ARNs for the +// ListAttached*Policies calls, keeping those whose path starts with pathPrefix. +func (s *IAMServiceImpl) attachedPolicies(ctx context.Context, accountID string, arns []string, pathPrefix string) []*iam.AttachedPolicy { var attached []*iam.AttachedPolicy - for _, arn := range user.AttachedPolicies { + for _, arn := range arns { // AWS-managed ARNs have no KV entry; report them from the ARN itself so // attach/list round-trips instead of silently dropping them. if isAWSManagedPolicyARN(arn) { - attached = append(attached, &iam.AttachedPolicy{ - PolicyArn: aws.String(arn), - PolicyName: aws.String(managedPolicyNameFromARN(arn)), - }) + if strings.HasPrefix(policyPathFromARN(arn), pathPrefix) { + attached = append(attached, &iam.AttachedPolicy{ + PolicyArn: aws.String(arn), + PolicyName: aws.String(managedPolicyNameFromARN(arn)), + }) + } continue } policy, err := s.getPolicyByARN(ctx, accountID, arn) if err != nil { - slog.Warn("ListAttachedUserPolicies: policy not found for ARN", "arn", arn, "err", err) + slog.Warn("attached policy not found for ARN", "arn", arn, "err", err) + continue + } + if !strings.HasPrefix(policy.Path, pathPrefix) { continue } attached = append(attached, &iam.AttachedPolicy{ @@ -1559,6 +1663,24 @@ func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.L PolicyName: aws.String(policy.PolicyName), }) } + return attached +} + +// policyPathFromARN returns the path of a policy ARN, e.g. +// arn:aws:iam::aws:policy/service-role/Name -> /service-role/. +func policyPathFromARN(arn string) string { + _, resource, _ := strings.Cut(arn, ":policy") + return resource[:strings.LastIndex(resource, "/")+1] +} + +func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) { + ctx := context.Background() + user, err := s.getUser(ctx, accountID, *input.UserName) + if err != nil { + return nil, err + } + + attached := s.attachedPolicies(ctx, accountID, user.AttachedPolicies, aws.StringValue(input.PathPrefix)) return &iam.ListAttachedUserPoliciesOutput{ AttachedPolicies: attached, @@ -1696,7 +1818,7 @@ func (s *IAMServiceImpl) ListUserPolicies(accountID string, input *iam.ListUserP // user writers (no CAS). func (s *IAMServiceImpl) TagUser(accountID string, input *iam.TagUserInput) (*iam.TagUserOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, foldedKeys); err != nil { return nil, err } @@ -1706,7 +1828,7 @@ func (s *IAMServiceImpl) TagUser(accountID string, input *iam.TagUserInput) (*ia return nil, err } - merged := mergeTags(user.Tags, input.Tags) + merged := mergeTags(user.Tags, input.Tags, foldedKeys) if len(merged) > maxTagsPerResource { return nil, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -1726,6 +1848,9 @@ func (s *IAMServiceImpl) TagUser(accountID string, input *iam.TagUserInput) (*ia // UntagUser removes the named tag keys from a user; unknown keys are a no-op. func (s *IAMServiceImpl) UntagUser(accountID string, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() userName := *input.UserName user, err := s.getUser(ctx, accountID, userName) @@ -1733,7 +1858,7 @@ func (s *IAMServiceImpl) UntagUser(accountID string, input *iam.UntagUserInput) return nil, err } - user.Tags = removeTagKeys(user.Tags, input.TagKeys) + user.Tags = removeTagKeys(user.Tags, input.TagKeys, foldedKeys) data, err := json.Marshal(user) if err != nil { @@ -1764,12 +1889,12 @@ func (s *IAMServiceImpl) ListUserTags(accountID string, input *iam.ListUserTagsI // record cannot undo a concurrent change of default version. func (s *IAMServiceImpl) TagPolicy(accountID string, input *iam.TagPolicyInput) (*iam.TagPolicyOutput, error) { ctx := context.Background() - if err := validateTags(input.Tags); err != nil { + if err := validateTags(input.Tags, exactKeys); err != nil { return nil, err } err := s.updatePolicyCAS(ctx, accountID, *input.PolicyArn, func(policy *Policy) (bool, error) { - merged := mergeTags(policy.Tags, input.Tags) + merged := mergeTags(policy.Tags, input.Tags, exactKeys) if len(merged) > maxTagsPerResource { return false, errors.New(awserrors.ErrorIAMLimitExceeded) } @@ -1786,9 +1911,12 @@ func (s *IAMServiceImpl) TagPolicy(accountID string, input *iam.TagPolicyInput) // UntagPolicy removes the named tag keys from a policy; unknown keys are a no-op. func (s *IAMServiceImpl) UntagPolicy(accountID string, input *iam.UntagPolicyInput) (*iam.UntagPolicyOutput, error) { + if err := validateTagKeys(input.TagKeys); err != nil { + return nil, err + } ctx := context.Background() err := s.updatePolicyCAS(ctx, accountID, *input.PolicyArn, func(policy *Policy) (bool, error) { - policy.Tags = removeTagKeys(policy.Tags, input.TagKeys) + policy.Tags = removeTagKeys(policy.Tags, input.TagKeys, exactKeys) return true, nil }) if err != nil { @@ -2086,10 +2214,10 @@ func isIAMNameChar(c byte) bool { // message AWS gives, which names the failing parameter. func validateIAMName(field, name string, maxLen int) error { if len(name) == 0 { - return lengthViolation(name, field, "greater than or equal to 1") + return lengthViolation(field, "greater than or equal to 1") } if len(name) > maxLen { - return lengthViolation(name, field, fmt.Sprintf("less than or equal to %d", maxLen)) + return lengthViolation(field, fmt.Sprintf("less than or equal to %d", maxLen)) } for i := range len(name) { if !isIAMNameChar(name[i]) { @@ -2100,9 +2228,33 @@ func validateIAMName(field, name string, maxLen int) error { return nil } -func lengthViolation(value, field, bound string) error { - return awserrors.Errorf(awserrors.ErrorIAMInvalidInput, - "1 validation error detected: Value '%s' at '%s' failed to satisfy constraint: Member must have length %s", value, field, bound) +// validateDescription enforces the 1000-character limit on a role or policy +// description. +func validateDescription(description *string) error { + if description != nil && utf8.RuneCountInString(*description) > maxDescriptionLength { + return lengthViolation("description", fmt.Sprintf("less than or equal to %d", maxDescriptionLength)) + } + return nil +} + +// lengthViolation is the ValidationError AWS returns for a member outside its +// length bounds; IAM leaves the value out of the message. +func lengthViolation(field, bound string) error { + return validationError([]string{fmt.Sprintf("Value at '%s' failed to satisfy constraint: Member must have length %s", field, bound)}) +} + +// Enum values in the order AWS lists them in a violation, not the model's. +var ( + policyScopeValues = []string{iam.PolicyScopeTypeAll, iam.PolicyScopeTypeLocal, iam.PolicyScopeTypeAws} + policyUsageValues = []string{iam.PolicyUsageTypePermissionsBoundary, iam.PolicyUsageTypePermissionsPolicy} +) + +// checkEnum refuses an optional member set to a value outside allowed. +func checkEnum(field string, value *string, allowed []string) error { + if value == nil || slices.Contains(allowed, *value) { + return nil + } + return enumViolation(field, allowed) } func enumViolation(field string, allowed []string) error { @@ -2127,7 +2279,7 @@ func validatePath(path string) error { "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.") } if len(path) > 512 { - return lengthViolation(path, "path", "less than or equal to 512") + return lengthViolation("path", "less than or equal to 512") } return nil } diff --git a/spinifex/handlers/iam/service_impl_test.go b/spinifex/handlers/iam/service_impl_test.go index 287b7e1df..56a863ff7 100644 --- a/spinifex/handlers/iam/service_impl_test.go +++ b/spinifex/handlers/iam/service_impl_test.go @@ -386,7 +386,7 @@ func TestCreateUser_InvalidName_TooLong(t *testing.T) { UserName: aws.String(longName), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreateUser_InvalidName_BadChars(t *testing.T) { @@ -422,7 +422,7 @@ func TestCreatePolicy_InvalidName(t *testing.T) { PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`), }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } func TestCreatePolicy_InvalidPath(t *testing.T) { @@ -438,6 +438,25 @@ func TestCreatePolicy_InvalidPath(t *testing.T) { assert.Contains(t, err.Error(), awserrors.ErrorValidationError) } +// AWS refuses an empty Path, as every other Create* does, and checks it before +// the policy document. +func TestCreatePolicy_EmptyPathRefusedBeforeDocument(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + + _, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("EmptyPath"), PolicyDocument: aws.String("{"), Path: aws.String(""), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, + "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters.") + + out, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("NoPath"), PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + assert.Equal(t, "/", aws.StringValue(out.Policy.Path)) +} + func TestValidatePolicyDocument_TooLarge(t *testing.T) { t.Parallel() largeDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"` + strings.Repeat("a", maxPolicyDocumentSize) + `"}]}` @@ -1264,7 +1283,7 @@ func TestCreatePolicy_InvalidTag(t *testing.T) { Tags: []*iam.Tag{{Key: aws.String(""), Value: aws.String("x")}}, }) assert.Error(t, err) - assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) // The rejected policy must not have been stored. _, err = svc.GetPolicy(testAccountID, &iam.GetPolicyInput{ @@ -1700,6 +1719,37 @@ func TestDeletePolicy_AttachedConflict_Group(t *testing.T) { // ListEntitiesForPolicy Tests // ============================================================================ +// The messages are the ones AWS returned for the same values. +func TestListPolicyCalls_EnumViolations(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + const ( + scopeMsg = "1 validation error detected: Value at 'scope' failed to satisfy constraint: Member must satisfy enum value set: [All, Local, AWS]" + usageMsg = "1 validation error detected: Value at 'policyUsageFilter' failed to satisfy constraint: Member must satisfy enum value set: [PermissionsBoundary, PermissionsPolicy]" + ) + _, err := svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{Scope: aws.String("Bogus")}) + requireIAMError(t, err, awserrors.ErrorValidationError, scopeMsg) + _, err = svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{PolicyUsageFilter: aws.String("Bogus")}) + requireIAMError(t, err, awserrors.ErrorValidationError, usageMsg) + _, err = svc.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{ + PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), PolicyUsageFilter: aws.String("Bogus"), + }) + requireIAMError(t, err, awserrors.ErrorValidationError, usageMsg) + + for _, scope := range iam.PolicyScopeType_Values() { + _, err = svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{Scope: aws.String(scope)}) + require.NoError(t, err, scope) + } + for _, usage := range iam.PolicyUsageType_Values() { + _, err = svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{PolicyUsageFilter: aws.String(usage)}) + require.NoError(t, err, usage) + _, err = svc.ListEntitiesForPolicy(testAccountID, &iam.ListEntitiesForPolicyInput{ + PolicyArn: aws.String("arn:aws:iam::aws:policy/ReadOnlyAccess"), PolicyUsageFilter: aws.String(usage), + }) + require.NoError(t, err, usage) + } +} + func TestListEntitiesForPolicy_Empty(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) @@ -2002,6 +2052,78 @@ func TestListAttachedUserPolicies(t *testing.T) { assert.True(t, names["ListPolicy2"]) } +func TestListAttachedPolicies_FilterByPathPrefix(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + root := createTestPolicy(t, svc, "RootPolicy") + team, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("TeamPolicy"), Path: aws.String("/team/a/"), PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + createTestUser(t, svc, "u") + createTestRole(t, svc, "r") + createTestGroup(t, svc, "g") + for _, arn := range []*string{root.Arn, team.Policy.Arn} { + _, err = svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{UserName: aws.String("u"), PolicyArn: arn}) + require.NoError(t, err) + _, err = svc.AttachRolePolicy(testAccountID, &iam.AttachRolePolicyInput{RoleName: aws.String("r"), PolicyArn: arn}) + require.NoError(t, err) + _, err = svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{GroupName: aws.String("g"), PolicyArn: arn}) + require.NoError(t, err) + } + + list := map[string]func(prefix *string) ([]*iam.AttachedPolicy, error){ + "user": func(prefix *string) ([]*iam.AttachedPolicy, error) { + out, err := svc.ListAttachedUserPolicies(testAccountID, &iam.ListAttachedUserPoliciesInput{UserName: aws.String("u"), PathPrefix: prefix}) + if err != nil { + return nil, err + } + return out.AttachedPolicies, nil + }, + "role": func(prefix *string) ([]*iam.AttachedPolicy, error) { + out, err := svc.ListAttachedRolePolicies(testAccountID, &iam.ListAttachedRolePoliciesInput{RoleName: aws.String("r"), PathPrefix: prefix}) + if err != nil { + return nil, err + } + return out.AttachedPolicies, nil + }, + "group": func(prefix *string) ([]*iam.AttachedPolicy, error) { + out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{GroupName: aws.String("g"), PathPrefix: prefix}) + if err != nil { + return nil, err + } + return out.AttachedPolicies, nil + }, + } + cases := []struct { + prefix *string + want []string + }{ + {nil, []string{"RootPolicy", "TeamPolicy"}}, + {aws.String("/"), []string{"RootPolicy", "TeamPolicy"}}, + {aws.String("/team/"), []string{"TeamPolicy"}}, + {aws.String("/other/"), nil}, + } + for identity, fn := range list { + for _, tc := range cases { + got, err := fn(tc.prefix) + require.NoError(t, err) + var names []string + for _, p := range got { + names = append(names, aws.StringValue(p.PolicyName)) + } + assert.ElementsMatch(t, tc.want, names, "%s with PathPrefix %v", identity, aws.StringValue(tc.prefix)) + } + } +} + +func TestPolicyPathFromARN(t *testing.T) { + t.Parallel() + assert.Equal(t, "/", policyPathFromARN("arn:aws:iam::aws:policy/ReadOnlyAccess")) + assert.Equal(t, "/service-role/", policyPathFromARN("arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM")) + assert.Equal(t, "/team/a/", policyPathFromARN("arn:aws:iam::000000000000:policy/team/a/TeamPolicy")) +} + func TestListAttachedUserPolicies_Empty(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) @@ -2584,7 +2706,7 @@ func TestIsIAMNameChar(t *testing.T) { func TestValidateIAMName(t *testing.T) { t.Parallel() const charset = "It must contain only alphanumeric characters and/or the following: +=,.@_-" - invalid, validation := awserrors.ErrorIAMInvalidInput, awserrors.ErrorValidationError + validation := awserrors.ErrorValidationError tests := []struct { name string field string @@ -2598,10 +2720,10 @@ func TestValidateIAMName(t *testing.T) { {"valid single char", "userName", "a", 64, "", ""}, {"valid at max length", "userName", strings.Repeat("a", 64), 64, "", ""}, {"valid policy at max length", "policyName", strings.Repeat("x", 128), 128, "", ""}, - {"empty", "userName", "", 64, invalid, - "1 validation error detected: Value '' at 'userName' failed to satisfy constraint: Member must have length greater than or equal to 1"}, - {"too long", "policyName", strings.Repeat("x", 129), 128, invalid, - "1 validation error detected: Value '" + strings.Repeat("x", 129) + "' at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128"}, + {"empty", "userName", "", 64, validation, + "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length greater than or equal to 1"}, + {"too long", "policyName", strings.Repeat("x", 129), 128, validation, + "1 validation error detected: Value at 'policyName' failed to satisfy constraint: Member must have length less than or equal to 128"}, {"space", "userName", "bad name!", 64, validation, "The specified value for userName is invalid. " + charset}, {"slash", "roleName", "alice/bob", 64, validation, "The specified value for roleName is invalid. " + charset}, {"colon", "groupName", "alice:bob", 128, validation, "The specified value for groupName is invalid. " + charset}, @@ -2632,7 +2754,7 @@ func TestValidatePath(t *testing.T) { t.Parallel() const shape = "The specified value for path is invalid. It must begin and end with / and contain only alphanumeric characters and/or / characters." tooLong := "/" + strings.Repeat("a", 511) + "/" - invalid, validation := awserrors.ErrorIAMInvalidInput, awserrors.ErrorValidationError + validation := awserrors.ErrorValidationError tests := []struct { name string input string @@ -2646,8 +2768,8 @@ func TestValidatePath(t *testing.T) { {"empty string", "", validation, shape}, {"just text", "noslash", validation, shape}, {"max length 512", "/" + strings.Repeat("a", 510) + "/", "", ""}, - {"over max length 513", tooLong, invalid, - "1 validation error detected: Value '" + tooLong + "' at 'path' failed to satisfy constraint: Member must have length less than or equal to 512"}, + {"over max length 513", tooLong, validation, + "1 validation error detected: Value at 'path' failed to satisfy constraint: Member must have length less than or equal to 512"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/spinifex/handlers/iam/tags_test.go b/spinifex/handlers/iam/tags_test.go index fe87aed92..24efe090d 100644 --- a/spinifex/handlers/iam/tags_test.go +++ b/spinifex/handlers/iam/tags_test.go @@ -30,22 +30,34 @@ func TestValidateTags(t *testing.T) { }{ {"nil slice", nil, ""}, {"valid tags", []*iam.Tag{sdkTag("env", "prod"), sdkTag("team", "")}, ""}, + {"every allowed symbol", []*iam.Tag{sdkTag("ok_.:/=+-@ key", "ok_.:/=+-@ value")}, ""}, + {"non-ASCII letters", []*iam.Tag{sdkTag("ключ", "значение")}, ""}, {"max key length", []*iam.Tag{sdkTag(strings.Repeat("k", maxTagKeyLength), "v")}, ""}, {"max value length", []*iam.Tag{sdkTag("k", strings.Repeat("v", maxTagValueLength))}, ""}, - {"nil value allowed", []*iam.Tag{{Key: aws.String("k")}}, ""}, - {"over 50 tags", tooMany, awserrors.ErrorIAMLimitExceeded}, - {"nil tag entry", []*iam.Tag{nil}, awserrors.ErrorIAMInvalidInput}, - {"nil key", []*iam.Tag{{Value: aws.String("v")}}, awserrors.ErrorIAMInvalidInput}, - {"empty key", []*iam.Tag{sdkTag("", "v")}, awserrors.ErrorIAMInvalidInput}, - {"over-length key", []*iam.Tag{sdkTag(strings.Repeat("k", maxTagKeyLength+1), "v")}, awserrors.ErrorIAMInvalidInput}, - {"over-length value", []*iam.Tag{sdkTag("k", strings.Repeat("v", maxTagValueLength+1))}, awserrors.ErrorIAMInvalidInput}, + {"max key length in multibyte characters", []*iam.Tag{sdkTag(strings.Repeat("é", maxTagKeyLength), "v")}, ""}, + {"max value length in multibyte characters", []*iam.Tag{sdkTag("k", strings.Repeat("é", maxTagValueLength))}, ""}, + {"over 50 tags", tooMany, awserrors.ErrorValidationError}, + {"reserved aws: prefix", []*iam.Tag{sdkTag("aws:thing", "v")}, awserrors.ErrorIAMInvalidInput}, + {"reserved prefix in any case", []*iam.Tag{sdkTag("AWS:thing", "v")}, awserrors.ErrorIAMInvalidInput}, + {"aws prefix without colon", []*iam.Tag{sdkTag("awsthing", "v")}, ""}, + {"constraint break wins over reserved prefix", []*iam.Tag{sdkTag("aws:thing", "v"), sdkTag("bad#", "v")}, awserrors.ErrorValidationError}, + {"nil tag entry", []*iam.Tag{nil}, awserrors.ErrorValidationError}, + {"nil key", []*iam.Tag{{Value: aws.String("v")}}, awserrors.ErrorValidationError}, + {"nil value", []*iam.Tag{{Key: aws.String("k")}}, awserrors.ErrorValidationError}, + {"empty key", []*iam.Tag{sdkTag("", "v")}, awserrors.ErrorValidationError}, + {"over-length key", []*iam.Tag{sdkTag(strings.Repeat("é", maxTagKeyLength+1), "v")}, awserrors.ErrorValidationError}, + {"over-length value", []*iam.Tag{sdkTag("k", strings.Repeat("é", maxTagValueLength+1))}, awserrors.ErrorValidationError}, + {"key outside pattern", []*iam.Tag{sdkTag("bad#key", "v")}, awserrors.ErrorValidationError}, + {"value outside pattern", []*iam.Tag{sdkTag("k", "bad\nvalue")}, awserrors.ErrorValidationError}, {"duplicate keys", []*iam.Tag{sdkTag("k", "1"), sdkTag("k", "2")}, awserrors.ErrorIAMInvalidInput}, + {"duplicate keys differing in case", []*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}, awserrors.ErrorIAMInvalidInput}, + {"constraint break wins over duplicate", []*iam.Tag{sdkTag("k", "1"), sdkTag("K", "2"), sdkTag("bad#", "v")}, awserrors.ErrorValidationError}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := validateTags(tc.tags) + err := validateTags(tc.tags, foldedKeys) if tc.wantErr == "" { require.NoError(t, err) return @@ -58,28 +70,104 @@ func TestValidateTags(t *testing.T) { } } -func TestValidateTags_NamesFailingMember(t *testing.T) { +// The messages are the ones AWS returned to TagUser for the same input. +func TestValidateTags_Messages(t *testing.T) { t.Parallel() cases := []struct { - name string - tags []*iam.Tag - wantMsg string + name string + tags []*iam.Tag + wantCode string + wantMsg string }{ - {"nil key", []*iam.Tag{sdkTag("a", "1"), {Value: aws.String("v")}}, - "1 validation error detected: Value null at 'tags.2.member.key' failed to satisfy constraint: Member must not be null"}, - {"over-length value", []*iam.Tag{sdkTag("a", "1"), sdkTag("b", "2"), sdkTag("k", strings.Repeat("v", maxTagValueLength+1))}, - "1 validation error detected: Value '" + strings.Repeat("v", maxTagValueLength+1) + "' at 'tags.3.member.value' failed to satisfy constraint: Member must have length less than or equal to 256"}, - {"duplicate keys", []*iam.Tag{sdkTag("k", "1"), sdkTag("k", "2")}, + {"nil value", []*iam.Tag{{Key: aws.String("novalue")}}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null"}, + {"key outside pattern", []*iam.Tag{sdkTag("bad#key", "v")}, awserrors.ErrorValidationError, + `1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`}, + {"value outside pattern", []*iam.Tag{sdkTag("k", "bad#value")}, awserrors.ErrorValidationError, + `1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]*`}, + {"over-length key", []*iam.Tag{sdkTag(strings.Repeat("é", maxTagKeyLength+1), "v")}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128"}, + {"over-length value", []*iam.Tag{sdkTag("k", strings.Repeat("é", maxTagValueLength+1))}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256"}, + {"empty key breaks length and pattern", []*iam.Tag{sdkTag("", "v")}, awserrors.ErrorValidationError, + "2 validation errors detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1; " + + `Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`}, + {"names the failing member", []*iam.Tag{sdkTag("a", "1"), {Value: aws.String("v")}}, awserrors.ErrorValidationError, + "1 validation error detected: Value at 'tags.2.member.key' failed to satisfy constraint: Member must not be null"}, + {"duplicate keys differing in case", []*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}, awserrors.ErrorIAMInvalidInput, "Duplicate tag keys found. Please note that Tag keys are case insensitive."}, + {"reserved aws: prefix", []*iam.Tag{sdkTag("aws:thing", "v")}, awserrors.ErrorIAMInvalidInput, + "Tag keys beginning with aws: are reserved for system use."}, + {"over 50 tags with a bad key", append(tagsNamed(maxTagsPerResource), sdkTag("bad#key", "v")), awserrors.ErrorValidationError, + "2 validation errors detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50; " + + `Value at 'tags.51.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - requireIAMError(t, validateTags(tc.tags), awserrors.ErrorIAMInvalidInput, tc.wantMsg) + requireIAMError(t, validateTags(tc.tags, foldedKeys), tc.wantCode, tc.wantMsg) }) } } +func TestValidateTagKeys(t *testing.T) { + t.Parallel() + keySet := `Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 128, ` + + `Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+, Member must not be null]` + tooMany := make([]*string, maxTagsPerResource+1) + for i := range tooMany { + tooMany[i] = aws.String(fmt.Sprintf("k%d", i)) + } + cases := []struct { + name string + keys []*string + wantMsg string + }{ + {"valid keys", []*string{aws.String("env"), aws.String(strings.Repeat("é", maxTagKeyLength))}, ""}, + {"empty key", []*string{aws.String("")}, "1 validation error detected: " + keySet}, + {"key outside pattern", []*string{aws.String("bad#key")}, "1 validation error detected: " + keySet}, + {"over-length key", []*string{aws.String(strings.Repeat("k", maxTagKeyLength+1))}, "1 validation error detected: " + keySet}, + {"several bad keys are one violation", []*string{aws.String("bad#"), aws.String("k"), aws.String("bad*")}, "1 validation error detected: " + keySet}, + {"nil key", []*string{nil}, "1 validation error detected: " + keySet}, + {"over 50 keys", tooMany, + "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := validateTagKeys(tc.keys) + if tc.wantMsg == "" { + require.NoError(t, err) + return + } + requireIAMError(t, err, awserrors.ErrorValidationError, tc.wantMsg) + }) + } +} + +// AWS checks the keys before looking the resource up. +func TestUntag_InvalidKeyRefusedBeforeLookup(t *testing.T) { + t.Parallel() + for _, ops := range allTagOps() { + t.Run(ops.resource, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + err := ops.untag(svc, ops.missingID, []*string{aws.String("bad#key")}) + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, awserrors.ErrorValidationError, code) + }) + } +} + +func tagsNamed(n int) []*iam.Tag { + tags := make([]*iam.Tag, n) + for i := range tags { + tags[i] = sdkTag(fmt.Sprintf("k%d", i), "v") + } + return tags +} + func TestMergeTags(t *testing.T) { t.Parallel() cases := []struct { @@ -95,6 +183,12 @@ func TestMergeTags(t *testing.T) { []*iam.Tag{sdkTag("a", "9"), sdkTag("c", "3")}, []Tag{{Key: "a", Value: "9"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}}, }, + { + "upsert ignores case and takes the new key", + []Tag{{Key: "Env", Value: "base"}, {Key: "b", Value: "2"}}, + []*iam.Tag{sdkTag("env", "lower")}, + []Tag{{Key: "env", Value: "lower"}, {Key: "b", Value: "2"}}, + }, { "nil key skipped", []Tag{{Key: "a", Value: "1"}}, @@ -112,16 +206,28 @@ func TestMergeTags(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := mergeTags(tc.existing, tc.add) + got := mergeTags(tc.existing, tc.add, foldedKeys) assert.Equal(t, tc.want, got) }) } } +func TestMergeAndRemoveTags_ExactKeys(t *testing.T) { + t.Parallel() + existing := []Tag{{Key: "Env", Value: "base"}} + merged := mergeTags(existing, []*iam.Tag{sdkTag("env", "lower")}, exactKeys) + assert.Equal(t, []Tag{{Key: "Env", Value: "base"}, {Key: "env", Value: "lower"}}, merged) + assert.Equal(t, merged, removeTagKeys(merged, []*string{aws.String("ENV")}, exactKeys)) + assert.Equal(t, []Tag{{Key: "env", Value: "lower"}}, removeTagKeys(merged, []*string{aws.String("Env")}, exactKeys)) + require.NoError(t, validateTags([]*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}, exactKeys)) + requireIAMError(t, validateTags([]*iam.Tag{sdkTag("dup", "1"), sdkTag("dup", "2")}, exactKeys), awserrors.ErrorIAMInvalidInput, + "Duplicate tag keys found. Please note that Tag keys are case insensitive.") +} + func TestMergeTags_DoesNotMutateExisting(t *testing.T) { t.Parallel() existing := []Tag{{Key: "a", Value: "1"}} - _ = mergeTags(existing, []*iam.Tag{sdkTag("a", "9")}) + _ = mergeTags(existing, []*iam.Tag{sdkTag("a", "9")}, foldedKeys) assert.Equal(t, []Tag{{Key: "a", Value: "1"}}, existing) } @@ -135,6 +241,7 @@ func TestRemoveTagKeys(t *testing.T) { want []Tag }{ {"remove one", []*string{aws.String("b")}, []Tag{{Key: "a", Value: "1"}, {Key: "c", Value: "3"}}}, + {"key case ignored", []*string{aws.String("B")}, []Tag{{Key: "a", Value: "1"}, {Key: "c", Value: "3"}}}, {"unknown key ignored", []*string{aws.String("zzz")}, existing}, {"nil key ignored", []*string{nil, aws.String("a")}, []Tag{{Key: "b", Value: "2"}, {Key: "c", Value: "3"}}}, {"remove all", []*string{aws.String("a"), aws.String("b"), aws.String("c")}, []Tag{}}, @@ -143,7 +250,7 @@ func TestRemoveTagKeys(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := removeTagKeys(existing, tc.keys) + got := removeTagKeys(existing, tc.keys, foldedKeys) assert.Equal(t, tc.want, got) }) } @@ -152,21 +259,29 @@ func TestRemoveTagKeys(t *testing.T) { // tagOps abstracts the per-resource tag/untag/list triple so the round-trip // behaviour is asserted identically for every taggable resource. type tagOps struct { - resource string - create func(t *testing.T, svc *IAMServiceImpl) string - missingID string - tag func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error - untag func(svc *IAMServiceImpl, id string, keys []*string) error - list func(svc *IAMServiceImpl, id string) ([]*iam.Tag, error) + resource string + keys keyCase + create func(t *testing.T, svc *IAMServiceImpl) string + // createTagged creates the resource with tags and returns the ID it would have. + createTagged func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) + missingID string + tag func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error + untag func(svc *IAMServiceImpl, id string, keys []*string) error + list func(svc *IAMServiceImpl, id string) ([]*iam.Tag, error) } func allTagOps() []tagOps { return []tagOps{ { resource: "user", + keys: foldedKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { return *createTestUser(t, svc, "tagme").UserName }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateUser(testAccountID, &iam.CreateUserInput{UserName: aws.String("tagged"), Tags: tags}) + return "tagged", err + }, missingID: "no-such-user", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagUser(testAccountID, &iam.TagUserInput{UserName: aws.String(id), Tags: tags}) @@ -186,9 +301,16 @@ func allTagOps() []tagOps { }, { resource: "role", + keys: foldedKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { return *createTestRole(t, svc, "tagme").RoleName }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("tagged"), AssumeRolePolicyDocument: aws.String(validTrustPolicy()), Tags: tags, + }) + return "tagged", err + }, missingID: "no-such-role", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagRole(testAccountID, &iam.TagRoleInput{RoleName: aws.String(id), Tags: tags}) @@ -208,6 +330,7 @@ func allTagOps() []tagOps { }, { resource: "policy", + keys: exactKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { out, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ PolicyName: aws.String("tagme"), @@ -216,6 +339,12 @@ func allTagOps() []tagOps { require.NoError(t, err) return *out.Policy.Arn }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("tagged"), PolicyDocument: aws.String(validPolicyDocument()), Tags: tags, + }) + return "arn:aws:iam::" + testAccountID + ":policy/tagged", err + }, missingID: "arn:aws:iam::" + testAccountID + ":policy/no-such-policy", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagPolicy(testAccountID, &iam.TagPolicyInput{PolicyArn: aws.String(id), Tags: tags}) @@ -235,9 +364,16 @@ func allTagOps() []tagOps { }, { resource: "instance profile", + keys: exactKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { return *createTestInstanceProfile(t, svc, "tagme").InstanceProfileName }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateInstanceProfile(testAccountID, &iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String("tagged"), Tags: tags, + }) + return "tagged", err + }, missingID: "no-such-profile", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagInstanceProfile(testAccountID, &iam.TagInstanceProfileInput{InstanceProfileName: aws.String(id), Tags: tags}) @@ -257,6 +393,7 @@ func allTagOps() []tagOps { }, { resource: "OIDC provider", + keys: exactKeys, create: func(t *testing.T, svc *IAMServiceImpl) string { out, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ Url: aws.String("https://oidc.example.com/id/TAGME"), @@ -264,6 +401,12 @@ func allTagOps() []tagOps { require.NoError(t, err) return *out.OpenIDConnectProviderArn }, + createTagged: func(svc *IAMServiceImpl, tags []*iam.Tag) (string, error) { + _, err := svc.CreateOpenIDConnectProvider(testAccountID, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String("https://oidc.example.com/id/TAGGED"), Tags: tags, + }) + return "arn:aws:iam::" + testAccountID + ":oidc-provider/oidc.example.com/id/TAGGED", err + }, missingID: "arn:aws:iam::" + testAccountID + ":oidc-provider/oidc.example.com/id/MISSING", tag: func(svc *IAMServiceImpl, id string, tags []*iam.Tag) error { _, err := svc.TagOpenIDConnectProvider(testAccountID, &iam.TagOpenIDConnectProviderInput{OpenIDConnectProviderArn: aws.String(id), Tags: tags}) @@ -292,6 +435,91 @@ func tagsAsMap(tags []*iam.Tag) map[string]string { return m } +func TestCreate_InvalidTagsRefusedAndNotStored(t *testing.T) { + t.Parallel() + cases := []struct { + name string + tags []*iam.Tag + wantCode string + }{ + {"nil value", []*iam.Tag{{Key: aws.String("k")}}, awserrors.ErrorValidationError}, + {"key outside pattern", []*iam.Tag{sdkTag("bad#key", "v")}, awserrors.ErrorValidationError}, + {"duplicate keys", []*iam.Tag{sdkTag("dup", "1"), sdkTag("dup", "2")}, awserrors.ErrorIAMInvalidInput}, + {"reserved aws: prefix", []*iam.Tag{sdkTag("aws:thing", "v")}, awserrors.ErrorIAMInvalidInput}, + } + for _, ops := range allTagOps() { + for _, tc := range cases { + t.Run(ops.resource+"/"+tc.name, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + id, err := ops.createTagged(svc, tc.tags) + require.Error(t, err) + code, ok := awserrors.ResolveErrorCode(err) + require.True(t, ok, "error must carry a registered code: %v", err) + assert.Equal(t, tc.wantCode, code) + + _, err = ops.list(svc, id) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error()) + }) + } + } +} + +func TestCreate_ValidTagsStored(t *testing.T) { + t.Parallel() + for _, ops := range allTagOps() { + t.Run(ops.resource, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + id, err := ops.createTagged(svc, []*iam.Tag{sdkTag("env", "prod"), sdkTag("team", "")}) + require.NoError(t, err) + got, err := ops.list(svc, id) + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "prod", "team": ""}, tagsAsMap(got)) + }) + } +} + +// AWS compares tag keys ignoring case on users and roles only: elsewhere Dup +// and dup are two tags, and neither tagging nor untagging in another case +// touches the stored key. +func TestResourceTagging_KeyCase(t *testing.T) { + t.Parallel() + for _, ops := range allTagOps() { + t.Run(ops.resource, func(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + id := ops.create(t, svc) + + err := ops.tag(svc, id, []*iam.Tag{sdkTag("Dup", "1"), sdkTag("dup", "2")}) + if ops.keys == foldedKeys { + requireIAMError(t, err, awserrors.ErrorIAMInvalidInput, "Duplicate tag keys found. Please note that Tag keys are case insensitive.") + } else { + require.NoError(t, err) + } + + require.NoError(t, ops.tag(svc, id, []*iam.Tag{sdkTag("Case", "upper")})) + require.NoError(t, ops.tag(svc, id, []*iam.Tag{sdkTag("case", "lower")})) + got, err := ops.list(svc, id) + require.NoError(t, err) + want := map[string]string{"Dup": "1", "dup": "2", "Case": "upper", "case": "lower"} + if ops.keys == foldedKeys { + want = map[string]string{"case": "lower"} + } + assert.Equal(t, want, tagsAsMap(got)) + + require.NoError(t, ops.untag(svc, id, []*string{aws.String("CASE")})) + got, err = ops.list(svc, id) + require.NoError(t, err) + if ops.keys == foldedKeys { + want = map[string]string{} + } + assert.Equal(t, want, tagsAsMap(got)) + }) + } +} + func TestResourceTagging_RoundTrip(t *testing.T) { t.Parallel() for _, ops := range allTagOps() { diff --git a/tests/integration/conformance-known-findings.md b/tests/integration/conformance-known-findings.md index 8549535fa..b296ad300 100644 --- a/tests/integration/conformance-known-findings.md +++ b/tests/integration/conformance-known-findings.md @@ -35,13 +35,13 @@ A rejection that succeeds is always a finding. A refusal counts as a pass only w Request findings and undeclared errors block only for services listed under `promotedRequestServices` in `conformance-promoted-services.json`, which starts empty; response promotion does not carry over. -The 2026-09-29 baseline sent 4047 requests across 371 operations: 980 pass, 506 findings, 96 undeclared errors, 2465 inconclusive. None of it had been checked against AWS, so each finding below is a lead to verify before any fix. Since then, EC2 range-checks `MaxResults` on six `Describe*` calls and refuses a tag resource type the operation cannot tag, as AWS answers both, which clears 12 EC2 findings. Every finding is traced to its cause, and grouped into fix items, in the mulga plan `docs/development/bugs/aws-integration-conformance-findings.md`. +The 2026-09-29 baseline sent 4047 requests across 371 operations: 980 pass, 506 findings, 96 undeclared errors, 2465 inconclusive. None of it had been checked against AWS, so each finding below is a lead to verify before any fix. Since then, EC2 range-checks `MaxResults` on six `Describe*` calls and refuses a tag resource type the operation cannot tag, as AWS answers both, which clears 12 EC2 findings. IAM tag and untag calls now refuse a tag with no value or outside the model's key and value character sets, count tag lengths in characters rather than bytes, treat keys that differ only in case as one key on users and roles but as two elsewhere, and refuse the reserved `aws:` prefix, each as AWS answered it. IAM also checks tags on every create, `PathPrefix` on every list call (and `ListAttached*Policies` now filters by it), the `Scope` and `PolicyUsageFilter` enums, role and policy `Description` length, role `MaxSessionDuration` against 3600–43200, OIDC client ID and thumbprint lengths, `ListEntitiesForPolicy` `PolicyArn` length and an empty `CreatePolicy` `Path`, and answers name and path length breaks with `ValidationError`, each verified against AWS. Every finding is traced to its cause, and grouped into fix items, in the mulga plan `docs/development/bugs/aws-integration-conformance-findings.md`. | Finding | Count | Notes | |---|---:|---| -| A request breaking a model constraint is accepted | 420 | ECS 116, IAM 112, ACM 56, ELBv2 48, ECR 47, EC2 22, RDS 7, STS 6, EKS 6. Examples: ACM, ELBv2 and IAM accept a tag without `Key`; EC2 network `Describe*` accept out-of-range `MaxResults`; ECS `CreateCapacityProvider` accepts every enum and range break inside `autoScalingGroupProvider` and `managedInstancesProvider`; ACM `RequestCertificate` accepts every break inside `DomainValidationOptions`; STS `GetSessionToken` accepts out-of-range `DurationSeconds` and a `SerialNumber` and `TokenCode` below their minimum length. | +| A request breaking a model constraint is accepted | 420 | ECS 116, IAM 112, ACM 56, ELBv2 48, ECR 47, EC2 22, RDS 7, STS 6, EKS 6. Examples: ACM and ELBv2 accept a tag without `Key`, as IAM did; EC2 network `Describe*` accepted out-of-range `MaxResults`; ECS `CreateCapacityProvider` accepts every enum and range break inside `autoScalingGroupProvider` and `managedInstancesProvider`; ACM `RequestCertificate` accepts every break inside `DomainValidationOptions`; STS `GetSessionToken` accepts out-of-range `DurationSeconds` and a `SerialNumber` and `TokenCode` below their minimum length. | | A model-valid request is refused as invalid | 74 | RDS 42, ECS 10, STS 10, EC2 8, EKS 2, IAM 2. Mostly deliberate limitations that say so: RDS `CreateDBInstance`, `ModifyDBInstance` and `RestoreDBInstanceFromDBSnapshot` members, STS session policies, tags and MFA, ECS `availabilityZoneRebalancing`. Others return a generic message whose cause is unresolved (ECS task and task-definition operations, EC2 `Modify*Attribute`), or add a member next to a seeded one that AWS may refuse in combination, such as EC2 `CreateCapacityReservation` `AvailabilityZoneId` beside `AvailabilityZone`. | -| A refusal uses an undeclared error code | 96 | ECR (90) and EKS (2) answer with `InvalidParameterValueException`; both models declare `InvalidParameterException`. IAM (4) answers `CreateGroup` and `Put*Policy` name-length breaks with `InvalidInput`, which those operations do not declare. | +| A refusal uses an undeclared error code | 96 | ECR (90) and EKS (2) answer with `InvalidParameterValueException`; both models declare `InvalidParameterException`. IAM (4) answered `CreateGroup` and `Put*Policy` name-length breaks with `InvalidInput`, which those operations do not declare; they now answer `ValidationError`, as AWS does. | How the sweep reaches its targets: diff --git a/tests/integration/iam_roles_test.go b/tests/integration/iam_roles_test.go index e9188942a..8df90a7be 100644 --- a/tests/integration/iam_roles_test.go +++ b/tests/integration/iam_roles_test.go @@ -108,10 +108,9 @@ func TestIAMRolesAndProfiles(t *testing.T) { require.Equal(t, "updated", aws.StringValue(got.Role.Description)) require.Equal(t, int64(7200), aws.Int64Value(got.Role.MaxSessionDuration)) - // Server-side MaxSessionDuration range guard (900-43200) isn't reachable - // via the AWS SDK: UpdateRoleInput carries min:"3600" so SDK.Validate() - // blocks values < 3600 before dispatch. The gateway guard is covered by - // handlers/iam/roles_test.go TestCreateRole_MaxSessionDuration_TooSmall. + // The MaxSessionDuration range guard (3600-43200) is unreachable here: the + // SDK refuses values below 3600 before dispatch. handlers/iam covers it in + // TestRole_MaxSessionDurationBounds. // UpdateAssumeRolePolicy — swap document, no enforcement yet. _, err = iamCli.UpdateAssumeRolePolicy(&iam.UpdateAssumeRolePolicyInput{