diff --git a/iamapi/controller.go b/iamapi/controller.go index 5b06eef1..672ba29f 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -61,7 +61,7 @@ func (c IAMApiController) CreateUser(ctx fiber.Ctx) (*Response, error) { return nil, err } - tags, err := iamutil.ParseTags(ctx) + tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded) if err != nil { return nil, err } @@ -235,6 +235,88 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) { }}, nil } +// TagUser adds or replaces tags on an existing user. AWS validates the +// request in full before it ever looks the user up, so a malformed tag on a +// non-existent user reports the tag error, not NoSuchEntity. +func (c IAMApiController) TagUser(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "TagUser", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded) + if err != nil { + return nil, err + } + if len(tags) == 0 { + debuglogger.Logf("missing required TagUser parameter: Tags") + return nil, iamerr.MissingValue("tags") + } + + if err := c.store.TagUser(ctx.Context(), userName, tags); err != nil { + debuglogger.Logf("failed to tag IAM user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.TagUserResponse{}}, nil +} + +// UntagUser removes the named tags from an existing user. Removal is +// idempotent: a key naming no current tag is not an error. +func (c IAMApiController) UntagUser(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "UntagUser", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + tagKeys, err := iamutil.ParseTagKeys(ctx) + if err != nil { + return nil, err + } + if len(tagKeys) == 0 { + debuglogger.Logf("missing required UntagUser parameter: TagKeys") + return nil, iamerr.MissingValue("tagKeys") + } + + if err := c.store.UntagUser(ctx.Context(), userName, tagKeys); err != nil { + debuglogger.Logf("failed to untag IAM user %q: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.UntagUserResponse{}}, nil +} + +func (c IAMApiController) ListUserTags(ctx fiber.Ctx) (*Response, error) { + userName, err := iamutil.GetUserName(ctx, "ListUserTags", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserTags") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListUserTags(ctx.Context(), storage.ListUserTagsInput{ + UserName: userName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM user %q tags: %v", userName, err) + return nil, err + } + + return &Response{Data: &types.ListUserTagsResponse{ + Result: types.ListUserTagsResult{ + Tags: types.Tags{Members: out.Tags}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) { userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName")) if err != nil { @@ -542,7 +624,7 @@ func (c IAMApiController) ListUserPolicies(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) { - roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxUserNameLen, iamerr.MissingValue("roleName")) + roleName, err := iamutil.GetRoleName(ctx, "CreateRole", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) if err != nil { return nil, err } @@ -580,7 +662,7 @@ func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) { return nil, err } - tags, err := iamutil.ParseTags(ctx) + tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded) if err != nil { return nil, err } @@ -626,7 +708,7 @@ func (c IAMApiController) CreateRole(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) GetRole(ctx fiber.Ctx) (*Response, error) { - roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + roleName, err := iamutil.GetRoleName(ctx, "GetRole", iamutil.MaxRoleNameLen, iamerr.MissingParameter("RoleName")) if err != nil { return nil, err } @@ -685,7 +767,7 @@ func (c IAMApiController) ListRoles(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) DeleteRole(ctx fiber.Ctx) (*Response, error) { - roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxUserLookupLen, iamerr.MissingParameter("RoleName")) + roleName, err := iamutil.GetRoleName(ctx, "DeleteRole", iamutil.MaxRoleNameLen, iamerr.MissingParameter("RoleName")) if err != nil { return nil, err } @@ -708,7 +790,7 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro return nil, err } - roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + roleName, err := iamutil.GetRoleName(ctx, "UpdateAssumeRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) if err != nil { return nil, err } @@ -737,6 +819,86 @@ func (c IAMApiController) UpdateAssumeRolePolicy(ctx fiber.Ctx) (*Response, erro return &Response{Data: &types.UpdateAssumeRolePolicyResponse{}}, nil } +// TagRole adds or replaces tags on an existing role. +func (c IAMApiController) TagRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "TagRole", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysFolded) + if err != nil { + return nil, err + } + if len(tags) == 0 { + debuglogger.Logf("missing required TagRole parameter: Tags") + return nil, iamerr.MissingValue("tags") + } + + if err := c.store.TagRole(ctx.Context(), roleName, tags); err != nil { + debuglogger.Logf("failed to tag IAM role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.TagRoleResponse{}}, nil +} + +// UntagRole removes the named tags from an existing role. Removal is +// idempotent: a key naming no current tag is not an error. +func (c IAMApiController) UntagRole(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "UntagRole", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + tagKeys, err := iamutil.ParseTagKeys(ctx) + if err != nil { + return nil, err + } + if len(tagKeys) == 0 { + debuglogger.Logf("missing required UntagRole parameter: TagKeys") + return nil, iamerr.MissingValue("tagKeys") + } + + if err := c.store.UntagRole(ctx.Context(), roleName, tagKeys); err != nil { + debuglogger.Logf("failed to untag IAM role %q: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.UntagRoleResponse{}}, nil +} + +func (c IAMApiController) ListRoleTags(ctx fiber.Ctx) (*Response, error) { + roleName, err := iamutil.GetRoleName(ctx, "ListRoleTags", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListRoleTags") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListRoleTags(ctx.Context(), storage.ListRoleTagsInput{ + RoleName: roleName, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM role %q tags: %v", roleName, err) + return nil, err + } + + return &Response{Data: &types.ListRoleTagsResponse{ + Result: types.ListRoleTagsResult{ + Tags: types.Tags{Members: out.Tags}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) { policyDocument, ok := iamutil.RequestParam(ctx, "PolicyDocument") if !ok { @@ -756,7 +918,7 @@ func (c IAMApiController) PutRolePolicy(ctx fiber.Ctx) (*Response, error) { return nil, err } - roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + roleName, err := iamutil.GetRoleName(ctx, "PutRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) if err != nil { return nil, err } @@ -793,7 +955,7 @@ func (c IAMApiController) GetRolePolicy(ctx fiber.Ctx) (*Response, error) { return nil, err } - roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + roleName, err := iamutil.GetRoleName(ctx, "GetRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) if err != nil { return nil, err } @@ -823,7 +985,7 @@ func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) { return nil, err } - roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + roleName, err := iamutil.GetRoleName(ctx, "DeleteRolePolicy", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) if err != nil { return nil, err } @@ -837,7 +999,7 @@ func (c IAMApiController) DeleteRolePolicy(ctx fiber.Ctx) (*Response, error) { } func (c IAMApiController) ListRolePolicies(ctx fiber.Ctx) (*Response, error) { - roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxUserLookupLen, iamerr.MissingValue("roleName")) + roleName, err := iamutil.GetRoleName(ctx, "ListRolePolicies", iamutil.MaxRoleNameLen, iamerr.MissingValue("roleName")) if err != nil { return nil, err } @@ -907,7 +1069,7 @@ func (c IAMApiController) CreateOpenIDConnectProvider(ctx fiber.Ctx) (*Response, thumbprints = iamutil.NormalizeThumbprintList(thumbprints) } - tags, err := iamutil.ParseTags(ctx) + tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysExact) if err != nil { return nil, err } @@ -1052,6 +1214,89 @@ func (c IAMApiController) UpdateOpenIDConnectProviderThumbprint(ctx fiber.Ctx) ( return &Response{Data: &types.UpdateOpenIDConnectProviderThumbprintResponse{}}, nil } +// TagOpenIDConnectProvider adds or replaces tags on an existing OIDC +// provider. Unlike the user and role tag actions, provider tag keys are +// compared exactly, so "env" and "ENV" are two distinct tags. +func (c IAMApiController) TagOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "TagOpenIDConnectProvider") + if err != nil { + return nil, err + } + + tags, err := iamutil.ParseTags(ctx, iamutil.TagKeysExact) + if err != nil { + return nil, err + } + if len(tags) == 0 { + debuglogger.Logf("missing required TagOpenIDConnectProvider parameter: Tags") + return nil, iamerr.MissingValue("tags") + } + + if err := c.store.TagOIDCProvider(ctx.Context(), arn, tags); err != nil { + debuglogger.Logf("failed to tag IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.TagOpenIDConnectProviderResponse{}}, nil +} + +// UntagOpenIDConnectProvider removes the named tags from an existing OIDC +// provider. Removal is idempotent: a key naming no current tag is not an +// error. +func (c IAMApiController) UntagOpenIDConnectProvider(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "UntagOpenIDConnectProvider") + if err != nil { + return nil, err + } + + tagKeys, err := iamutil.ParseTagKeys(ctx) + if err != nil { + return nil, err + } + if len(tagKeys) == 0 { + debuglogger.Logf("missing required UntagOpenIDConnectProvider parameter: TagKeys") + return nil, iamerr.MissingValue("tagKeys") + } + + if err := c.store.UntagOIDCProvider(ctx.Context(), arn, tagKeys); err != nil { + debuglogger.Logf("failed to untag IAM OIDC provider %q: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.UntagOpenIDConnectProviderResponse{}}, nil +} + +func (c IAMApiController) ListOpenIDConnectProviderTags(ctx fiber.Ctx) (*Response, error) { + arn, err := iamutil.GetOIDCProviderArn(ctx, "ListOpenIDConnectProviderTags") + if err != nil { + return nil, err + } + + maxItems, err := iamutil.ParseMaxItems(ctx, "ListOpenIDConnectProviderTags") + if err != nil { + return nil, err + } + + marker, _ := iamutil.RequestParam(ctx, "Marker") + out, err := c.store.ListOIDCProviderTags(ctx.Context(), storage.ListOIDCProviderTagsInput{ + Arn: arn, + Marker: marker, + MaxItems: maxItems, + }) + if err != nil { + debuglogger.Logf("failed to list IAM OIDC provider %q tags: %v", arn, err) + return nil, err + } + + return &Response{Data: &types.ListOpenIDConnectProviderTagsResponse{ + Result: types.ListOpenIDConnectProviderTagsResult{ + Tags: types.Tags{Members: out.Tags}, + IsTruncated: out.IsTruncated, + Marker: out.Marker, + }, + }}, nil +} + func (c IAMApiController) AssumeRoleWithWebIdentity(ctx fiber.Ctx) (*Response, error) { rawRoleArn, ok := iamutil.RequestParam(ctx, "RoleArn") if !ok || rawRoleArn == "" { diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 39bf5156..6a935f10 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -21,6 +21,7 @@ import ( "encoding/hex" "encoding/json" "encoding/xml" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -364,8 +365,8 @@ func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) { "Tags.member.1.Value": {"test"}, }, status: http.StatusBadRequest, - code: "MissingParameter", - message: "The request must contain the parameter Tags.member.1.Key.", + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null", }, { name: "missing tag value", @@ -375,8 +376,8 @@ func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) { "Tags.member.1.Key": {"env"}, }, status: http.StatusBadRequest, - code: "MissingParameter", - message: "The request must contain the parameter Tags.member.1.Value.", + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null", }, } @@ -533,6 +534,435 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) { requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.") } +func TestIAMApiControllerUserTagLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + if got := listUserTags(t, server, "alice"); len(got.Tags.Members) != 0 || got.IsTruncated { + t.Fatalf("ListUserTags on a fresh user = %#v, want no tags", got) + } + + tagUser(t, server, "alice", map[string]string{"env": "prod", "team": "storage", "empty": ""}) + + got := listUserTags(t, server, "alice") + // Sorted by key, regardless of the order they were added in. + want := []iamtypes.Tag{{Key: "empty", Value: ""}, {Key: "env", Value: "prod"}, {Key: "team", Value: "storage"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags = %#v, want %#v", got.Tags.Members, want) + } + + // A repeated key replaces its value in place; a differently-cased key + // is the same tag, and the new casing wins. + tagUser(t, server, "alice", map[string]string{"env": "staging"}) + tagUser(t, server, "alice", map[string]string{"TEAM": "compute"}) + + got = listUserTags(t, server, "alice") + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}, {Key: "env", Value: "staging"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after overwrite = %#v, want %#v", got.Tags.Members, want) + } + + // GetUser reports the same tags the tag actions maintain. + getUser := doIAMAction(t, server, url.Values{"Action": {"GetUser"}, "UserName": {"alice"}}) + var getResp struct { + Result struct{ User iamtypes.User } `xml:"GetUserResult"` + } + unmarshalXML(t, readBody(t, getUser), &getResp) + if len(getResp.Result.User.Tags) != 3 { + t.Fatalf("GetUser Tags = %#v, want 3 tags", getResp.Result.User.Tags) + } + + // Removal is case-insensitive, and a key naming no tag is not an error. + untag := doIAMAction(t, server, url.Values{ + "Action": {"UntagUser"}, + "UserName": {"alice"}, + "TagKeys.member.1": {"EnV"}, + "TagKeys.member.2": {"never-existed"}, + }) + if untag.StatusCode != http.StatusOK { + t.Fatalf("UntagUser status = %d, body=%s", untag.StatusCode, readBody(t, untag)) + } + + got = listUserTags(t, server, "alice") + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after untag = %#v, want %#v", got.Tags.Members, want) + } +} + +func TestIAMApiControllerListUserTagsPagination(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + tagUser(t, server, "alice", map[string]string{"a": "1", "b": "2", "c": "3"}) + + var seen []iamtypes.Tag + marker := "" + for page := 1; ; page++ { + params := url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"1"}} + if marker != "" { + params.Set("Marker", marker) + } + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListUserTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out struct { + Result iamtypes.ListUserTagsResult `xml:"ListUserTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + + if len(out.Result.Tags.Members) != 1 { + t.Fatalf("page %d holds %d tags, want 1", page, len(out.Result.Tags.Members)) + } + seen = append(seen, out.Result.Tags.Members...) + if !out.Result.IsTruncated { + if out.Result.Marker != "" { + t.Fatalf("final page Marker = %q, want empty", out.Result.Marker) + } + break + } + marker = out.Result.Marker + } + + want := []iamtypes.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}} + if !slices.Equal(seen, want) { + t.Fatalf("paged tags = %#v, want %#v", seen, want) + } +} + +func TestIAMApiControllerTagUserExceedsQuota(t *testing.T) { + server := newIAMControllerTestServer(t) + + create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if create.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create)) + } + + atQuota := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}} + for i := 1; i <= storage.MaxTagsPerResource; i++ { + atQuota.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + atQuota.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + if resp := doIAMAction(t, server, atQuota); resp.StatusCode != http.StatusOK { + t.Fatalf("TagUser with %d tags status = %d, body=%s", storage.MaxTagsPerResource, resp.StatusCode, readBody(t, resp)) + } + + // Replacing an existing key at the quota is fine: the total doesn't grow. + replace := doIAMAction(t, server, url.Values{ + "Action": {"TagUser"}, "UserName": {"alice"}, + "Tags.member.1.Key": {"k1"}, "Tags.member.1.Value": {"replaced"}, + }) + if replace.StatusCode != http.StatusOK { + t.Fatalf("TagUser replacing at quota status = %d, body=%s", replace.StatusCode, readBody(t, replace)) + } + + // One more distinct key does not fit. + overflow := doIAMAction(t, server, url.Values{ + "Action": {"TagUser"}, "UserName": {"alice"}, + "Tags.member.1.Key": {"overflow"}, "Tags.member.1.Value": {"x"}, + }) + requireIAMError(t, overflow, http.StatusConflict, "Sender", "LimitExceeded", + "The number of tags has reached the maximum limit.") +} + +func TestIAMApiControllerUserTagValidationErrors(t *testing.T) { + tooManyTags := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + tooManyTagKeys := url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTagKeys.Set(fmt.Sprintf("TagKeys.member.%d", i), fmt.Sprintf("k%d", i)) + } + + tests := []struct { + name string + setupUser bool + params url.Values + status int + code string + message string + }{ + { + name: "tag missing user name", + params: url.Values{"Action": {"TagUser"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing tags", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing key", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing value", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag empty key", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {""}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1", + }, + { + name: "tag key too long", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {strings.Repeat("k", 129)}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "tag invalid key characters", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `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}_.:/=+\-@]+`, + }, + { + name: "tag value too long", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {strings.Repeat("v", 257)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", + }, + { + name: "tag invalid value characters", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"bad*value"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `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}_.:/=+\-@]*`, + }, + { + name: "tag duplicate keys", + setupUser: true, + params: url.Values{ + "Action": {"TagUser"}, "UserName": {"alice"}, + "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"a"}, + "Tags.member.2.Key": {"ENV"}, "Tags.member.2.Value": {"b"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + { + name: "tag too many tags", + setupUser: true, + params: tooManyTags, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "tag invalid user name characters", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"bad!name"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for userName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "tag user name too long", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {strings.Repeat("u", 129)}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + // A malformed tag is reported before the user is looked up. + name: "tag non existing user with invalid tag", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"nosuchuser"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `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}_.:/=+\-@]+`, + }, + { + name: "tag non existing user", + setupUser: true, + params: url.Values{"Action": {"TagUser"}, "UserName": {"nosuchuser"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nosuchuser cannot be found.", + }, + { + name: "untag missing user name", + params: url.Values{"Action": {"UntagUser"}, "TagKeys.member.1": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag missing tag keys", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag empty key", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {""}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag key too long", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {strings.Repeat("k", 129)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag invalid key characters", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {"bad*key"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag too many tag keys", + setupUser: true, + params: tooManyTagKeys, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "untag non existing user", + setupUser: true, + params: url.Values{"Action": {"UntagUser"}, "UserName": {"nosuchuser"}, "TagKeys.member.1": {"env"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nosuchuser cannot be found.", + }, + { + name: "list missing user name", + params: url.Values{"Action": {"ListUserTags"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null", + }, + { + name: "list max items too small", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"0"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", + }, + { + name: "list max items too large", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", + }, + { + name: "list max items not a number", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"abc"}}, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "list non existing user", + setupUser: true, + params: url.Values{"Action": {"ListUserTags"}, "UserName": {"nosuchuser"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The user with name nosuchuser cannot be found.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupUser { + resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +// invalidTagKeysMessage is UntagUser's single response to every malformed +// TagKeys member, whatever the specific constraint that failed. +const invalidTagKeysMessage = `1 validation error detected: 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]` + +func tagUser(t *testing.T, server *IAMApiServer, userName string, tags map[string]string) { + t.Helper() + + params := url.Values{"Action": {"TagUser"}, "UserName": {userName}} + i := 1 + for key, value := range tags { + params.Set(fmt.Sprintf("Tags.member.%d.Key", i), key) + params.Set(fmt.Sprintf("Tags.member.%d.Value", i), value) + i++ + } + + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("TagUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func listUserTags(t *testing.T, server *IAMApiServer, userName string) iamtypes.ListUserTagsResult { + t.Helper() + + resp := doIAMAction(t, server, url.Values{"Action": {"ListUserTags"}, "UserName": {userName}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListUserTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out struct { + Result iamtypes.ListUserTagsResult `xml:"ListUserTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + return out.Result +} + func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) { server := newIAMControllerTestServer(t) @@ -803,7 +1233,7 @@ func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) { params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"alice"}, "MaxItems": {"1001"}}, status: http.StatusBadRequest, code: "ValidationError", - message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", }, } @@ -1039,6 +1469,450 @@ func TestIAMApiControllerRoleLifecycle(t *testing.T) { requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role with name my-role cannot be found.") } +func TestIAMApiControllerRoleTagLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + createTestRoleForTrust(t, server, "my-role", validTrustPolicy) + + if got := listRoleTags(t, server, "my-role"); len(got.Tags.Members) != 0 || got.IsTruncated { + t.Fatalf("ListRoleTags on a fresh role = %#v, want no tags", got) + } + + tagRole(t, server, "my-role", map[string]string{"env": "prod", "team": "storage", "empty": ""}) + + got := listRoleTags(t, server, "my-role") + // Sorted by key, regardless of the order they were added in. + want := []iamtypes.Tag{{Key: "empty", Value: ""}, {Key: "env", Value: "prod"}, {Key: "team", Value: "storage"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags = %#v, want %#v", got.Tags.Members, want) + } + + // A repeated key replaces its value in place; a differently-cased key + // is the same tag, and the new casing wins. + tagRole(t, server, "my-role", map[string]string{"env": "staging"}) + tagRole(t, server, "my-role", map[string]string{"TEAM": "compute"}) + + got = listRoleTags(t, server, "my-role") + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}, {Key: "env", Value: "staging"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after overwrite = %#v, want %#v", got.Tags.Members, want) + } + + // GetRole reports the same tags the tag actions maintain. + getRole := doIAMAction(t, server, url.Values{"Action": {"GetRole"}, "RoleName": {"my-role"}}) + var getResp struct { + Result struct{ Role iamtypes.Role } `xml:"GetRoleResult"` + } + unmarshalXML(t, readBody(t, getRole), &getResp) + if len(getResp.Result.Role.Tags) != 3 { + t.Fatalf("GetRole Tags = %#v, want 3 tags", getResp.Result.Role.Tags) + } + + // Removal is case-insensitive, and a key naming no tag is not an error. + untag := doIAMAction(t, server, url.Values{ + "Action": {"UntagRole"}, + "RoleName": {"my-role"}, + "TagKeys.member.1": {"EnV"}, + "TagKeys.member.2": {"never-existed"}, + }) + if untag.StatusCode != http.StatusOK { + t.Fatalf("UntagRole status = %d, body=%s", untag.StatusCode, readBody(t, untag)) + } + + got = listRoleTags(t, server, "my-role") + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after untag = %#v, want %#v", got.Tags.Members, want) + } +} + +// TestIAMApiControllerRoleTagsIndependentOfUserTags covers a role and a user +// sharing a name: they are separate entities, so neither one's tags leak +// into the other's. +func TestIAMApiControllerRoleTagsIndependentOfUserTags(t *testing.T) { + server := newIAMControllerTestServer(t) + createTestRoleForTrust(t, server, "shared", validTrustPolicy) + if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"shared"}}); resp.StatusCode != http.StatusOK { + t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + tagRole(t, server, "shared", map[string]string{"owner": "role"}) + tagUser(t, server, "shared", map[string]string{"owner": "user"}) + + if got := listRoleTags(t, server, "shared").Tags.Members; !slices.Equal(got, []iamtypes.Tag{{Key: "owner", Value: "role"}}) { + t.Fatalf("role tags = %#v", got) + } + if got := listUserTags(t, server, "shared").Tags.Members; !slices.Equal(got, []iamtypes.Tag{{Key: "owner", Value: "user"}}) { + t.Fatalf("user tags = %#v", got) + } + + untag := doIAMAction(t, server, url.Values{ + "Action": {"UntagRole"}, "RoleName": {"shared"}, "TagKeys.member.1": {"owner"}, + }) + if untag.StatusCode != http.StatusOK { + t.Fatalf("UntagRole status = %d, body=%s", untag.StatusCode, readBody(t, untag)) + } + if got := listRoleTags(t, server, "shared").Tags.Members; len(got) != 0 { + t.Fatalf("role tags after untag = %#v, want none", got) + } + if got := listUserTags(t, server, "shared").Tags.Members; !slices.Equal(got, []iamtypes.Tag{{Key: "owner", Value: "user"}}) { + t.Fatalf("user tags after untagging the role = %#v", got) + } +} + +func TestIAMApiControllerListRoleTagsPagination(t *testing.T) { + server := newIAMControllerTestServer(t) + createTestRoleForTrust(t, server, "my-role", validTrustPolicy) + tagRole(t, server, "my-role", map[string]string{"a": "1", "b": "2", "c": "3"}) + + var seen []iamtypes.Tag + marker := "" + for page := 1; ; page++ { + params := url.Values{"Action": {"ListRoleTags"}, "RoleName": {"my-role"}, "MaxItems": {"1"}} + if marker != "" { + params.Set("Marker", marker) + } + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListRoleTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out struct { + Result iamtypes.ListRoleTagsResult `xml:"ListRoleTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + + if len(out.Result.Tags.Members) != 1 { + t.Fatalf("page %d holds %d tags, want 1", page, len(out.Result.Tags.Members)) + } + seen = append(seen, out.Result.Tags.Members...) + if !out.Result.IsTruncated { + if out.Result.Marker != "" { + t.Fatalf("final page Marker = %q, want empty", out.Result.Marker) + } + break + } + marker = out.Result.Marker + } + + want := []iamtypes.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}} + if !slices.Equal(seen, want) { + t.Fatalf("paged tags = %#v, want %#v", seen, want) + } +} + +func TestIAMApiControllerTagRoleExceedsQuota(t *testing.T) { + server := newIAMControllerTestServer(t) + createTestRoleForTrust(t, server, "my-role", validTrustPolicy) + + atQuota := url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}} + for i := 1; i <= storage.MaxTagsPerResource; i++ { + atQuota.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + atQuota.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + if resp := doIAMAction(t, server, atQuota); resp.StatusCode != http.StatusOK { + t.Fatalf("TagRole with %d tags status = %d, body=%s", storage.MaxTagsPerResource, resp.StatusCode, readBody(t, resp)) + } + + // Replacing an existing key at the quota is fine: the total doesn't grow. + replace := doIAMAction(t, server, url.Values{ + "Action": {"TagRole"}, "RoleName": {"my-role"}, + "Tags.member.1.Key": {"k1"}, "Tags.member.1.Value": {"replaced"}, + }) + if replace.StatusCode != http.StatusOK { + t.Fatalf("TagRole replacing at quota status = %d, body=%s", replace.StatusCode, readBody(t, replace)) + } + + // One more distinct key does not fit. + overflow := doIAMAction(t, server, url.Values{ + "Action": {"TagRole"}, "RoleName": {"my-role"}, + "Tags.member.1.Key": {"overflow"}, "Tags.member.1.Value": {"x"}, + }) + requireIAMError(t, overflow, http.StatusConflict, "Sender", "LimitExceeded", + "The number of tags has reached the maximum limit.") +} + +func TestIAMApiControllerRoleTagValidationErrors(t *testing.T) { + tooManyTags := url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + tooManyTagKeys := url.Values{"Action": {"UntagRole"}, "RoleName": {"my-role"}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTagKeys.Set(fmt.Sprintf("TagKeys.member.%d", i), fmt.Sprintf("k%d", i)) + } + + tests := []struct { + name string + setupRole bool + params url.Values + status int + code string + message string + }{ + { + name: "tag missing role name", + params: url.Values{"Action": {"TagRole"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing tags", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing key", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing value", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Key": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag empty key", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Key": {""}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1", + }, + { + name: "tag key too long", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Key": {strings.Repeat("k", 129)}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "tag invalid key characters", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `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}_.:/=+\-@]+`, + }, + { + name: "tag value too long", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {strings.Repeat("v", 257)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", + }, + { + name: "tag invalid value characters", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"my-role"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"bad*value"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `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}_.:/=+\-@]*`, + }, + { + name: "tag duplicate keys", + setupRole: true, + params: url.Values{ + "Action": {"TagRole"}, "RoleName": {"my-role"}, + "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"a"}, + "Tags.member.2.Key": {"ENV"}, "Tags.member.2.Value": {"b"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + }, + { + name: "tag too many tags", + setupRole: true, + params: tooManyTags, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "tag invalid role name characters", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"bad!name"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "The specified value for roleName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-", + }, + { + name: "tag role name too long", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {strings.Repeat("r", 65)}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must have length less than or equal to 64", + }, + { + // A malformed tag is reported before the role is looked up. + name: "tag non existing role with invalid tag", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"nosuchrole"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: `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}_.:/=+\-@]+`, + }, + { + name: "tag non existing role", + setupRole: true, + params: url.Values{"Action": {"TagRole"}, "RoleName": {"nosuchrole"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nosuchrole cannot be found.", + }, + { + name: "untag missing role name", + params: url.Values{"Action": {"UntagRole"}, "TagKeys.member.1": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag missing tag keys", + setupRole: true, + params: url.Values{"Action": {"UntagRole"}, "RoleName": {"my-role"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag empty key", + setupRole: true, + params: url.Values{"Action": {"UntagRole"}, "RoleName": {"my-role"}, "TagKeys.member.1": {""}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag key too long", + setupRole: true, + params: url.Values{"Action": {"UntagRole"}, "RoleName": {"my-role"}, "TagKeys.member.1": {strings.Repeat("k", 129)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag invalid key characters", + setupRole: true, + params: url.Values{"Action": {"UntagRole"}, "RoleName": {"my-role"}, "TagKeys.member.1": {"bad*key"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag too many tag keys", + setupRole: true, + params: tooManyTagKeys, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "untag non existing role", + setupRole: true, + params: url.Values{"Action": {"UntagRole"}, "RoleName": {"nosuchrole"}, "TagKeys.member.1": {"env"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nosuchrole cannot be found.", + }, + { + name: "list missing role name", + params: url.Values{"Action": {"ListRoleTags"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'roleName' failed to satisfy constraint: Member must not be null", + }, + { + name: "list max items too small", + setupRole: true, + params: url.Values{"Action": {"ListRoleTags"}, "RoleName": {"my-role"}, "MaxItems": {"0"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", + }, + { + name: "list max items too large", + setupRole: true, + params: url.Values{"Action": {"ListRoleTags"}, "RoleName": {"my-role"}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", + }, + { + name: "list max items not a number", + setupRole: true, + params: url.Values{"Action": {"ListRoleTags"}, "RoleName": {"my-role"}, "MaxItems": {"abc"}}, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "list non existing role", + setupRole: true, + params: url.Values{"Action": {"ListRoleTags"}, "RoleName": {"nosuchrole"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: "The role with name nosuchrole cannot be found.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupRole { + createTestRoleForTrust(t, server, "my-role", validTrustPolicy) + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func tagRole(t *testing.T, server *IAMApiServer, roleName string, tags map[string]string) { + t.Helper() + + params := url.Values{"Action": {"TagRole"}, "RoleName": {roleName}} + i := 1 + for key, value := range tags { + params.Set(fmt.Sprintf("Tags.member.%d.Key", i), key) + params.Set(fmt.Sprintf("Tags.member.%d.Value", i), value) + i++ + } + + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("TagRole status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func listRoleTags(t *testing.T, server *IAMApiServer, roleName string) iamtypes.ListRoleTagsResult { + t.Helper() + + resp := doIAMAction(t, server, url.Values{"Action": {"ListRoleTags"}, "RoleName": {roleName}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListRoleTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out struct { + Result iamtypes.ListRoleTagsResult `xml:"ListRoleTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + return out.Result +} + func TestIAMApiControllerCreateRoleValidationErrors(t *testing.T) { tests := []struct { name string @@ -1616,7 +2490,7 @@ func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) { params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"my-role"}, "MaxItems": {"1001"}}, status: http.StatusBadRequest, code: "ValidationError", - message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", }, } @@ -1994,6 +2868,8 @@ func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) { message: "Thumbprint list must contain fewer than 5 entries.", }, { + // Provider tag keys are compared exactly, so only a + // byte-identical repeat is a duplicate. name: "duplicate tag keys", params: url.Values{ "Action": {"CreateOpenIDConnectProvider"}, @@ -2001,12 +2877,12 @@ func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) { "ThumbprintList.member.1": {strings.Repeat("a", 40)}, "Tags.member.1.Key": {"key"}, "Tags.member.1.Value": {"one"}, - "Tags.member.2.Key": {"KEY"}, + "Tags.member.2.Key": {"key"}, "Tags.member.2.Value": {"two"}, }, status: http.StatusBadRequest, code: "InvalidInput", - message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + message: "Duplicate tag keys found.", }, } @@ -2019,6 +2895,456 @@ func TestIAMApiControllerCreateOIDCProviderValidationErrors(t *testing.T) { } } +// TestIAMApiControllerOIDCProviderTagLifecycle covers the provider tagging +// actions' distinguishing trait: tag keys are compared exactly, so "env" +// and "ENV" are two independent tags rather than one. +func TestIAMApiControllerOIDCProviderTagLifecycle(t *testing.T) { + server := newIAMControllerTestServer(t) + arn := createTestOIDCProviderForTrust(t, server, "https://tags.example.com", "") + + if got := listOIDCProviderTags(t, server, arn); len(got.Tags.Members) != 0 || got.IsTruncated { + t.Fatalf("ListOpenIDConnectProviderTags on a fresh provider = %#v, want no tags", got) + } + + tagOIDCProvider(t, server, arn, map[string]string{"env": "prod", "team": "storage", "empty": ""}) + + got := listOIDCProviderTags(t, server, arn) + // Sorted by key, regardless of the order they were added in. + want := []iamtypes.Tag{{Key: "empty", Value: ""}, {Key: "env", Value: "prod"}, {Key: "team", Value: "storage"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags = %#v, want %#v", got.Tags.Members, want) + } + + // A repeated key replaces its value in place; a differently-cased key + // is a different tag and is added alongside. + tagOIDCProvider(t, server, arn, map[string]string{"env": "staging"}) + tagOIDCProvider(t, server, arn, map[string]string{"TEAM": "compute"}) + + got = listOIDCProviderTags(t, server, arn) + want = []iamtypes.Tag{ + {Key: "TEAM", Value: "compute"}, + {Key: "empty", Value: ""}, + {Key: "env", Value: "staging"}, + {Key: "team", Value: "storage"}, + } + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after overwrite = %#v, want %#v", got.Tags.Members, want) + } + + // GetOpenIDConnectProvider reports the same tags the tag actions maintain. + get := doIAMAction(t, server, url.Values{"Action": {"GetOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}) + var getOut iamtypes.GetOpenIDConnectProviderResponse + unmarshalXML(t, readBody(t, get), &getOut) + if len(getOut.Result.Tags) != 4 { + t.Fatalf("GetOpenIDConnectProvider Tags = %#v, want 4 tags", getOut.Result.Tags) + } + + // Removal matches keys exactly, and a key naming no tag is not an error. + untag := doIAMAction(t, server, url.Values{ + "Action": {"UntagOpenIDConnectProvider"}, + "OpenIDConnectProviderArn": {arn}, + "TagKeys.member.1": {"team"}, + "TagKeys.member.2": {"EnV"}, + "TagKeys.member.3": {"never-existed"}, + }) + if untag.StatusCode != http.StatusOK { + t.Fatalf("UntagOpenIDConnectProvider status = %d, body=%s", untag.StatusCode, readBody(t, untag)) + } + + got = listOIDCProviderTags(t, server, arn) + want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}, {Key: "env", Value: "staging"}} + if !slices.Equal(got.Tags.Members, want) { + t.Fatalf("Tags after untag = %#v, want %#v", got.Tags.Members, want) + } +} + +// TestIAMApiControllerOIDCProviderTagsIndependentPerProvider covers two +// providers carrying the same tag keys: each set is its own. +func TestIAMApiControllerOIDCProviderTagsIndependentPerProvider(t *testing.T) { + server := newIAMControllerTestServer(t) + first := createTestOIDCProviderForTrust(t, server, "https://first.example.com", "") + second := createTestOIDCProviderForTrust(t, server, "https://second.example.com", "") + + tagOIDCProvider(t, server, first, map[string]string{"owner": "first"}) + tagOIDCProvider(t, server, second, map[string]string{"owner": "second"}) + + untag := doIAMAction(t, server, url.Values{ + "Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {first}, "TagKeys.member.1": {"owner"}, + }) + if untag.StatusCode != http.StatusOK { + t.Fatalf("UntagOpenIDConnectProvider status = %d, body=%s", untag.StatusCode, readBody(t, untag)) + } + + if got := listOIDCProviderTags(t, server, first).Tags.Members; len(got) != 0 { + t.Fatalf("first provider tags after untag = %#v, want none", got) + } + if got := listOIDCProviderTags(t, server, second).Tags.Members; !slices.Equal(got, []iamtypes.Tag{{Key: "owner", Value: "second"}}) { + t.Fatalf("second provider tags = %#v", got) + } +} + +func TestIAMApiControllerListOIDCProviderTagsPagination(t *testing.T) { + server := newIAMControllerTestServer(t) + arn := createTestOIDCProviderForTrust(t, server, "https://paged.example.com", "") + tagOIDCProvider(t, server, arn, map[string]string{"a": "1", "b": "2", "c": "3"}) + + var seen []iamtypes.Tag + marker := "" + for page := 1; ; page++ { + params := url.Values{ + "Action": {"ListOpenIDConnectProviderTags"}, + "OpenIDConnectProviderArn": {arn}, + "MaxItems": {"1"}, + } + if marker != "" { + params.Set("Marker", marker) + } + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListOpenIDConnectProviderTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + var out struct { + Result iamtypes.ListOpenIDConnectProviderTagsResult `xml:"ListOpenIDConnectProviderTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + + if len(out.Result.Tags.Members) != 1 { + t.Fatalf("page %d holds %d tags, want 1", page, len(out.Result.Tags.Members)) + } + seen = append(seen, out.Result.Tags.Members...) + if !out.Result.IsTruncated { + if out.Result.Marker != "" { + t.Fatalf("final page Marker = %q, want empty", out.Result.Marker) + } + break + } + marker = out.Result.Marker + } + + want := []iamtypes.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}} + if !slices.Equal(seen, want) { + t.Fatalf("paged tags = %#v, want %#v", seen, want) + } +} + +func TestIAMApiControllerTagOIDCProviderExceedsQuota(t *testing.T) { + server := newIAMControllerTestServer(t) + arn := createTestOIDCProviderForTrust(t, server, "https://quota.example.com", "") + + atQuota := url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}} + for i := 1; i <= storage.MaxTagsPerResource; i++ { + atQuota.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + atQuota.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + if resp := doIAMAction(t, server, atQuota); resp.StatusCode != http.StatusOK { + t.Fatalf("TagOpenIDConnectProvider with %d tags status = %d, body=%s", storage.MaxTagsPerResource, resp.StatusCode, readBody(t, resp)) + } + + // Replacing an existing key at the quota is fine: the total doesn't grow. + replace := doIAMAction(t, server, url.Values{ + "Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, + "Tags.member.1.Key": {"k1"}, "Tags.member.1.Value": {"replaced"}, + }) + if replace.StatusCode != http.StatusOK { + t.Fatalf("TagOpenIDConnectProvider replacing at quota status = %d, body=%s", replace.StatusCode, readBody(t, replace)) + } + + // A differently-cased key is a new tag, so it overflows the quota. + overflow := doIAMAction(t, server, url.Values{ + "Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, + "Tags.member.1.Key": {"K1"}, "Tags.member.1.Value": {"x"}, + }) + requireIAMError(t, overflow, http.StatusConflict, "Sender", "LimitExceeded", "The number of tags has reached the maximum limit.") +} + +func TestIAMApiControllerOIDCProviderTagValidationErrors(t *testing.T) { + const arn = "arn:aws:iam::000000000000:oidc-provider/tags.example.com" + + tooManyTags := url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i)) + tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i)) + } + tooManyTagKeys := url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}} + for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ { + tooManyTagKeys.Set(fmt.Sprintf("TagKeys.member.%d", i), fmt.Sprintf("k%d", i)) + } + + const missingArn = "arn:aws:iam::000000000000:oidc-provider/nosuch.example.com" + const notFoundMessage = "OpenId connect Provider " + missingArn + " cannot be found." + + tests := []struct { + name string + setupProvider bool + params url.Values + status int + code string + message string + }{ + { + name: "tag missing arn", + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag arn too short", + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {"arn:aws:iam::1"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must have length greater than or equal to 20", + }, + { + name: "tag wrong resource type in arn", + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {"arn:aws:iam::000000000000:user/alice"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "Invalid resource type in ARN", + }, + { + name: "tag missing tags", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing key", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag missing value", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null", + }, + { + name: "tag empty key", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {""}, "Tags.member.1.Value": {"prod"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1", + }, + { + name: "tag key too long", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {strings.Repeat("k", 129)}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128", + }, + { + name: "tag invalid key characters", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "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}_.:/=+\\-@]+", + }, + { + name: "tag value too long", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {strings.Repeat("v", 257)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", + }, + { + name: "tag invalid value characters", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"bad*value"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "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}_.:/=+\\-@]*", + }, + { + name: "tag duplicate keys", + setupProvider: true, + params: url.Values{ + "Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, + "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}, + "Tags.member.2.Key": {"env"}, "Tags.member.2.Value": {"staging"}, + }, + status: http.StatusBadRequest, + code: "InvalidInput", + message: "Duplicate tag keys found.", + }, + { + name: "tag too many tags", + setupProvider: true, + params: tooManyTags, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "tag invalid tag on non existing provider", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {missingArn}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "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}_.:/=+\\-@]+", + }, + { + name: "tag non existing provider", + setupProvider: true, + params: url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {missingArn}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: notFoundMessage, + }, + { + name: "untag missing arn", + params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "TagKeys.member.1": {"env"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag missing tag keys", + setupProvider: true, + params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must not be null", + }, + { + name: "untag empty tag key", + setupProvider: true, + params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "TagKeys.member.1": {""}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag tag key too long", + setupProvider: true, + params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "TagKeys.member.1": {strings.Repeat("k", 129)}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag invalid tag key", + setupProvider: true, + params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}, "TagKeys.member.1": {"bad*key"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: invalidTagKeysMessage, + }, + { + name: "untag too many tag keys", + setupProvider: true, + params: tooManyTagKeys, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50", + }, + { + name: "untag non existing provider", + setupProvider: true, + params: url.Values{"Action": {"UntagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {missingArn}, "TagKeys.member.1": {"env"}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: notFoundMessage, + }, + { + name: "list missing arn", + params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'openIDConnectProviderArn' failed to satisfy constraint: Member must not be null", + }, + { + name: "list max items too low", + setupProvider: true, + params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {arn}, "MaxItems": {"0"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", + }, + { + name: "list max items too high", + setupProvider: true, + params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {arn}, "MaxItems": {"1001"}}, + status: http.StatusBadRequest, + code: "ValidationError", + message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", + }, + { + name: "list max items not a number", + setupProvider: true, + params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {arn}, "MaxItems": {"abc"}}, + status: http.StatusBadRequest, + code: "MalformedInput", + message: "", + }, + { + name: "list non existing provider", + setupProvider: true, + params: url.Values{"Action": {"ListOpenIDConnectProviderTags"}, "OpenIDConnectProviderArn": {missingArn}}, + status: http.StatusNotFound, + code: "NoSuchEntity", + message: notFoundMessage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newIAMControllerTestServer(t) + if tt.setupProvider { + createTestOIDCProviderForTrust(t, server, "https://tags.example.com", "") + } + resp := doIAMAction(t, server, tt.params) + requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message) + }) + } +} + +func tagOIDCProvider(t *testing.T, server *IAMApiServer, arn string, tags map[string]string) { + t.Helper() + + params := url.Values{"Action": {"TagOpenIDConnectProvider"}, "OpenIDConnectProviderArn": {arn}} + i := 1 + for key, value := range tags { + params.Set(fmt.Sprintf("Tags.member.%d.Key", i), key) + params.Set(fmt.Sprintf("Tags.member.%d.Value", i), value) + i++ + } + + resp := doIAMAction(t, server, params) + if resp.StatusCode != http.StatusOK { + t.Fatalf("TagOpenIDConnectProvider status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } +} + +func listOIDCProviderTags(t *testing.T, server *IAMApiServer, arn string) iamtypes.ListOpenIDConnectProviderTagsResult { + t.Helper() + + resp := doIAMAction(t, server, url.Values{ + "Action": {"ListOpenIDConnectProviderTags"}, + "OpenIDConnectProviderArn": {arn}, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ListOpenIDConnectProviderTags status = %d, body=%s", resp.StatusCode, readBody(t, resp)) + } + + var out struct { + Result iamtypes.ListOpenIDConnectProviderTagsResult `xml:"ListOpenIDConnectProviderTagsResult"` + } + unmarshalXML(t, readBody(t, resp), &out) + return out.Result +} + func TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) { store, err := storage.New(storage.Config{Dir: t.TempDir()}) if err != nil { diff --git a/iamapi/iamerr/errors.go b/iamapi/iamerr/errors.go index 533b6bd3..bf6831ca 100644 --- a/iamapi/iamerr/errors.go +++ b/iamapi/iamerr/errors.go @@ -57,11 +57,17 @@ const ( ErrInvalidContentLength ErrThrottling ErrTooManyTags + ErrTooManyTagKeys + ErrInvalidTagKeys + ErrTagLimitExceeded ErrInvalidPathPrefix ErrDuplicateTagKeys + ErrDuplicateExactTagKeys ErrInvalidAccessKeyIDChars ErrDeleteConflict ErrDeleteConflictPolicies + ErrMaxItemsTooLow + ErrMaxItemsTooHigh ) type APIError interface { @@ -221,12 +227,48 @@ var errorCodeResponse = map[ErrorCode]Error{ Message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50", HTTPStatusCode: http.StatusBadRequest, }, + ErrTooManyTagKeys: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidTagKeys: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: 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]", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrTagLimitExceeded: { + Type: TypeSender, + Code: "LimitExceeded", + Message: "The number of tags has reached the maximum limit.", + HTTPStatusCode: http.StatusConflict, + }, + ErrMaxItemsTooLow: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMaxItemsTooHigh: { + Type: TypeSender, + Code: "ValidationError", + Message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000", + HTTPStatusCode: http.StatusBadRequest, + }, ErrDuplicateTagKeys: { Type: TypeSender, Code: "InvalidInput", Message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.", HTTPStatusCode: http.StatusBadRequest, }, + ErrDuplicateExactTagKeys: { + Type: TypeSender, + Code: "InvalidInput", + Message: "Duplicate tag keys found.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrInvalidAccessKeyIDChars: { Type: TypeSender, Code: "ValidationError", @@ -405,10 +447,6 @@ func PathTooLong(field string, maxLength int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength)) } -func InvalidMaxItems(value string) Error { - return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", value)) -} - func AccessKeyIDTooShort(minLength int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'accessKeyId' failed to satisfy constraint: Member must have length greater than or equal to %d", minLength)) } @@ -429,6 +467,22 @@ func InvalidTagKey(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+", index)) } +// MissingTagKey reports a Tags member supplying a Value with no Key. +func MissingTagKey(index int) Error { + return MissingValue(fmt.Sprintf("tags.%d.member.key", index)) +} + +// MissingTagValue reports a Tags member supplying a Key with no Value. A +// tag value may be empty, but the parameter itself must be present. +func MissingTagValue(index int) Error { + return MissingValue(fmt.Sprintf("tags.%d.member.value", index)) +} + +// TagKeyTooShort reports an empty tag key +func TagKeyTooShort(index int) Error { + return ValueTooShort(fmt.Sprintf("tags.%d.member.key", index), 1) +} + func TagValueTooLong(index int) Error { return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", index)) } @@ -493,6 +547,9 @@ func NoSuchEntityOIDCProviderGet(arn string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("OpenIDConnect Provider not found for arn %s", arn), http.StatusNotFound) } +// NoSuchEntityOIDCProviderDelete is the wording DeleteOpenIDConnectProvider +// and the provider tagging actions use, distinct from the one +// NoSuchEntityOIDCProviderGet reports. func NoSuchEntityOIDCProviderDelete(arn string) Error { return newSenderError("NoSuchEntity", fmt.Sprintf("OpenId connect Provider %s cannot be found.", arn), http.StatusNotFound) } diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go index 71510693..6747d604 100644 --- a/iamapi/internal/iammiddleware/policy.go +++ b/iamapi/internal/iammiddleware/policy.go @@ -173,18 +173,21 @@ func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string case "GetUser": return getUserResource(ctx, store) case "DeleteUser", "UpdateUser", "CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey", - "ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies": + "ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies", + "TagUser", "UntagUser", "ListUserTags": return existingUserResource(ctx, store) case "GetAccessKeyLastUsed": return accessKeyOwnerResource(ctx, store) case "CreateRole": return newRoleResource(ctx), nil - case "GetRole", "DeleteRole", "UpdateAssumeRolePolicy", "PutRolePolicy", "GetRolePolicy", "DeleteRolePolicy", "ListRolePolicies": + case "GetRole", "DeleteRole", "UpdateAssumeRolePolicy", "PutRolePolicy", "GetRolePolicy", "DeleteRolePolicy", "ListRolePolicies", + "TagRole", "UntagRole", "ListRoleTags": return existingRoleResource(ctx, store) case "CreateOpenIDConnectProvider": return newOIDCProviderResource(ctx), nil case "GetOpenIDConnectProvider", "DeleteOpenIDConnectProvider", "AddClientIDToOpenIDConnectProvider", - "RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint": + "RemoveClientIDFromOpenIDConnectProvider", "UpdateOpenIDConnectProviderThumbprint", + "TagOpenIDConnectProvider", "UntagOpenIDConnectProvider", "ListOpenIDConnectProviderTags": arn, _ := iamutil.RequestParam(ctx, "OpenIDConnectProviderArn") if arn == "" { return "", nil @@ -380,8 +383,12 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri } switch action { - case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider": - addRequestTagContext(condCtx, ctx) + case "CreateUser", "CreateRole", "TagUser", "TagRole": + addRequestTagContext(condCtx, ctx, iamutil.TagKeysFolded) + case "CreateOpenIDConnectProvider", "TagOpenIDConnectProvider": + addRequestTagContext(condCtx, ctx, iamutil.TagKeysExact) + case "UntagUser", "UntagRole", "UntagOpenIDConnectProvider": + addTagKeysContext(condCtx, ctx) } return condCtx @@ -450,13 +457,13 @@ func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) { // addRequestTagContext populates aws:RequestTag/ and aws:TagKeys from // the request's Tags parameter, parsed the same way the controller parses it -// for the actual create call. A parse failure (e.g. a malformed tag) is left -// unpopulated rather than surfaced here — the controller performs the same -// parse independently and will reject the request with the specific -// tag-validation error afterward, so no create can succeed with tags that -// silently evaded a tag-scoped Condition. -func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { - tags, err := iamutil.ParseTags(ctx) +// for the actual create or tag call. A parse failure (e.g. a malformed tag) +// is left unpopulated rather than surfaced here — the controller performs +// the same parse independently and will reject the request with the +// specific tag-validation error afterward, so no write can succeed with +// tags that silently evaded a tag-scoped Condition. +func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx, keyCase iamutil.TagKeyCase) { + tags, err := iamutil.ParseTags(ctx, keyCase) if err != nil || len(tags) == 0 { return } @@ -468,6 +475,17 @@ func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { condCtx["aws:TagKeys"] = keys } +// addTagKeysContext populates aws:TagKeys from the request's TagKeys +// parameter. The untag actions supply keys without values, so aws:TagKeys +// is the only tag key they can be scoped by +func addTagKeysContext(condCtx map[string][]string, ctx fiber.Ctx) { + keys, err := iamutil.ParseTagKeys(ctx) + if err != nil || len(keys) == 0 { + return + } + condCtx["aws:TagKeys"] = keys +} + // CallerArn identifies identity the way real IAM error messages do: the // user's own Arn, or the assumed-role session Arn. func CallerArn(identity types.Identity) string { diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index e1c2d981..c746811b 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -29,19 +29,21 @@ import ( ) const ( - DefaultAccountID = "000000000000" - DefaultUserPath = "/" - DefaultMaxItems = 100 - MaxListItems = 1000 - MaxUserNameLen = 64 - MaxUserLookupLen = 128 - MaxPathLen = 512 - userIDPrefix = "AIDA" - userIDRandomLen = 17 - userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - maxTagKeyLen = 128 - maxTagValLen = 256 + DefaultAccountID = "000000000000" + DefaultUserPath = "/" + DefaultMaxItems = 100 + MaxListItems = 1000 + MaxUserNameLen = 64 + MaxUserLookupLen = 128 + MaxPathLen = 512 + userIDPrefix = "AIDA" + userIDRandomLen = 17 + userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + maxTagKeyLen = 128 + maxTagValLen = 256 + MaxTagMembersPerRequest = 50 + MaxRoleNameLen = 64 roleIDPrefix = "AROA" roleIDRandomLen = 17 @@ -184,16 +186,63 @@ func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) { } parsed, err := strconv.ParseInt(rawMaxItems, 10, 32) - if err != nil || parsed < 1 || parsed > MaxListItems { - debuglogger.Logf("invalid %s MaxItems value %q: parse_error=%v", operation, rawMaxItems, err) - return 0, iamerr.InvalidMaxItems(rawMaxItems) + if err != nil { + debuglogger.Logf("malformed %s MaxItems value %q: %v", operation, rawMaxItems, err) + return 0, iamerr.MalformedInput() + } + if parsed < 1 { + debuglogger.Logf("invalid %s MaxItems value %q", operation, rawMaxItems) + return 0, iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow) + } + if parsed > MaxListItems { + debuglogger.Logf("invalid %s MaxItems value %q", operation, rawMaxItems) + return 0, iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh) } return int32(parsed), nil } -// ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list. -func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { +// TagKeyCase selects how a resource's tag keys are compared. IAM users and +// roles fold key case, so "env" and "ENV" name the same tag; OIDC providers +// compare keys exactly, so both can be carried at once. +type TagKeyCase int + +const ( + TagKeysFolded TagKeyCase = iota + TagKeysExact +) + +// Equal reports whether a and b name the same tag key under c. +func (c TagKeyCase) Equal(a, b string) bool { + if c == TagKeysExact { + return a == b + } + return strings.EqualFold(a, b) +} + +// normalize maps key to the form that identifies its tag under c, for use +// as a map key. +func (c TagKeyCase) normalize(key string) string { + if c == TagKeysExact { + return key + } + return strings.ToLower(key) +} + +// duplicateErr is the error reported when one request supplies the same tag +// key twice under c. +func (c TagKeyCase) duplicateErr() iamerr.Error { + if c == TagKeysExact { + return iamerr.GetAPIError(iamerr.ErrDuplicateExactTagKeys) + } + return iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys) +} + +// ParseTags reads IAM tag members from the request (up to +// MaxTagMembersPerRequest), validates each, and returns the list. +// Duplicate keys are detected under keyCase, the tagged resource's own +// key-comparison rule. +func ParseTags(ctx fiber.Ctx, keyCase TagKeyCase) ([]types.Tag, error) { var tags []types.Tag seen := map[string]struct{}{} @@ -206,26 +255,26 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { if !hasKey && !hasValue { break } - if len(tags) >= 50 { - debuglogger.Logf("IAM user tag count exceeds maximum: max=%d", 50) + if len(tags) >= MaxTagMembersPerRequest { + debuglogger.Logf("IAM tag count exceeds maximum: max=%d", MaxTagMembersPerRequest) return nil, iamerr.GetAPIError(iamerr.ErrTooManyTags) } if !hasKey { debuglogger.Logf("missing required IAM tag parameter: %s", keyName) - return nil, iamerr.MissingParameter(keyName) + return nil, iamerr.MissingTagKey(i) } if !hasValue { debuglogger.Logf("missing required IAM tag parameter: %s", valueName) - return nil, iamerr.MissingParameter(valueName) + return nil, iamerr.MissingTagValue(i) } if err := validateTag(i, key, value); err != nil { return nil, err } - normalizedKey := strings.ToLower(key) + normalizedKey := keyCase.normalize(key) if _, ok := seen[normalizedKey]; ok { debuglogger.Logf("duplicate IAM tag key: %q", key) - return nil, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys) + return nil, keyCase.duplicateErr() } seen[normalizedKey] = struct{}{} @@ -235,6 +284,33 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) { return tags, nil } +// ParseTagKeys reads the request's TagKeys members (up to +// MaxTagMembersPerRequest), validates each, and returns the list. Unlike +// ParseTags, duplicate keys are accepted: removing the same key twice is a +// no-op, so AWS has no reason to reject it. +func ParseTagKeys(ctx fiber.Ctx) ([]string, error) { + var keys []string + + for i := 1; ; i++ { + key, ok := RequestParam(ctx, fmt.Sprintf("TagKeys.member.%d", i)) + if !ok { + break + } + if len(keys) >= MaxTagMembersPerRequest { + debuglogger.Logf("IAM tag key count exceeds maximum: max=%d", MaxTagMembersPerRequest) + return nil, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys) + } + if key == "" || len(key) > maxTagKeyLen || !tagKeyPattern.MatchString(key) { + debuglogger.Logf("invalid IAM tag key: index=%d value=%q", i, key) + return nil, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys) + } + + keys = append(keys, key) + } + + return keys, nil +} + // ValidateName checks that name (an IAM identity or policy name, e.g. // userName or policyName) is non-empty, matches the allowed character set, // and fits within maxLength. @@ -329,7 +405,11 @@ func validateTag(index int, key, value string) error { debuglogger.Logf("IAM tag key exceeds maximum length: index=%d length=%d max=%d", index, len(key), maxTagKeyLen) return iamerr.TagKeyTooLong(index) } - if key == "" || !tagKeyPattern.MatchString(key) { + if key == "" { + debuglogger.Logf("empty IAM tag key: index=%d", index) + return iamerr.TagKeyTooShort(index) + } + if !tagKeyPattern.MatchString(key) { debuglogger.Logf("invalid IAM tag key: index=%d value=%q", index, key) return iamerr.InvalidTagKey(index) } diff --git a/iamapi/router.go b/iamapi/router.go index 6820cd1c..d68e1e8d 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -65,6 +65,10 @@ func (r *IAMApiRouter) Init() { "GetUser": r.Ctrl.GetUser, "ListUsers": r.Ctrl.ListUsers, "UpdateUser": r.Ctrl.UpdateUser, + // User Tagging + "TagUser": r.Ctrl.TagUser, + "UntagUser": r.Ctrl.UntagUser, + "ListUserTags": r.Ctrl.ListUserTags, // User Access Key CRUD "CreateAccessKey": r.Ctrl.CreateAccessKey, "UpdateAccessKey": r.Ctrl.UpdateAccessKey, @@ -82,6 +86,10 @@ func (r *IAMApiRouter) Init() { "ListRoles": r.Ctrl.ListRoles, "DeleteRole": r.Ctrl.DeleteRole, "UpdateAssumeRolePolicy": r.Ctrl.UpdateAssumeRolePolicy, + // Role Tagging + "TagRole": r.Ctrl.TagRole, + "UntagRole": r.Ctrl.UntagRole, + "ListRoleTags": r.Ctrl.ListRoleTags, // Role Inline Policy CRUD "PutRolePolicy": r.Ctrl.PutRolePolicy, "GetRolePolicy": r.Ctrl.GetRolePolicy, @@ -95,6 +103,10 @@ func (r *IAMApiRouter) Init() { "AddClientIDToOpenIDConnectProvider": r.Ctrl.AddClientIDToOpenIDConnectProvider, "RemoveClientIDFromOpenIDConnectProvider": r.Ctrl.RemoveClientIDFromOpenIDConnectProvider, "UpdateOpenIDConnectProviderThumbprint": r.Ctrl.UpdateOpenIDConnectProviderThumbprint, + // OIDC Provider Tagging + "TagOpenIDConnectProvider": r.Ctrl.TagOpenIDConnectProvider, + "UntagOpenIDConnectProvider": r.Ctrl.UntagOpenIDConnectProvider, + "ListOpenIDConnectProviderTags": r.Ctrl.ListOpenIDConnectProviderTags, // STS actions (routed through this same endpoint; see stsActions) "AssumeRoleWithWebIdentity": r.Ctrl.AssumeRoleWithWebIdentity, "GetCallerIdentity": r.Ctrl.GetCallerIdentity, diff --git a/iamapi/storage/common.go b/iamapi/storage/common.go new file mode 100644 index 00000000..a34386b4 --- /dev/null +++ b/iamapi/storage/common.go @@ -0,0 +1,288 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package storage + +import ( + "errors" + "slices" + "strings" + "time" + + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/internal/iamutil" + "github.com/versity/versitygw/iamapi/types" +) + +// MaxAccessKeysPerUser is the maximum number of access keys a single IAM +// user may hold at once, matching the AWS IAM quota. +const MaxAccessKeysPerUser = 2 + +// MaxTagsPerResource is the maximum number of tags a single IAM user, role +// or OIDC provider may carry at once, matching the AWS IAM quota. +const MaxTagsPerResource = 50 + +// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of +// all of a single IAM user's inline policy documents combined +const MaxInlinePolicyBytesPerUser = 2048 + +// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of +// all of a single IAM role's inline policy documents combined +const MaxInlinePolicyBytesPerRole = 10240 + +// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single +// OIDC provider may hold at once +const MaxClientIDsPerOIDCProvider = 100 + +// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a +// single account may hold +const MaxOIDCProvidersPerAccount = 100 + +// MaxActiveSessionsPerRole bounds how many currently-unexpired +// AssumeRoleWithWebIdentity sessions a single role may have at once. +// AWS manages and rate-limits STS as a hosted service with no +// customer-visible equivalent quota to match for fidelity; this exists +// purely as local resource protection, since without it a single valid +// federated token can be replayed indefinitely to grow the session +// store — every InternalStore rewrite, or Vault KV path/metadata entry — +// without bound. Chosen generously enough to not constrain any legitimate +// workload's concurrent session count. +// +// A var, not a const, so tests can temporarily lower it rather than paying +// the cost of actually creating 1000 sessions to exercise the cap. +var MaxActiveSessionsPerRole = 1000 + +var ( + ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") + ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") + ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") + // ErrSessionNotFound is returned by GetSession when accessKeyID names no + // session, or names one whose Expiration has already passed. + ErrSessionNotFound = errors.New("iamapi: session not found") +) + +type ListUsersInput struct { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListUsersOutput struct { + Users []types.User + IsTruncated bool + Marker string +} + +type ListUserTagsInput struct { + UserName string + Marker string + MaxItems int32 +} + +// ListTagsOutput is the paginated tag window ListUserTags and ListRoleTags +// both return. +type ListTagsOutput struct { + Tags []types.Tag + IsTruncated bool + Marker string +} + +type UpdateUserInput struct { + UserName string + NewPath string + NewUserName string + NewArn string +} + +type CreateAccessKeyInput struct { + UserName string + AccessKeyID string + SecretAccessKey string + Status string + CreateDate time.Time +} + +type UpdateAccessKeyInput struct { + UserName string + AccessKeyID string + Status string +} + +type ListAccessKeysInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListAccessKeysOutput struct { + AccessKeys []types.AccessKeyMetadata + IsTruncated bool + Marker string +} + +type GetAccessKeyLastUsedOutput struct { + UserName string + LastUsedDate time.Time + ServiceName string + Region string +} + +type PutUserPolicyInput struct { + UserName string + PolicyName string + PolicyDocument string +} + +type ListUserPoliciesInput struct { + UserName string + Marker string + MaxItems int32 +} + +type ListUserPoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + +type ListRolesInput struct { + PathPrefix string + Marker string + MaxItems int32 +} + +type ListRolesOutput struct { + Roles []types.Role + IsTruncated bool + Marker string +} + +type UpdateAssumeRolePolicyInput struct { + RoleName string + PolicyDocument string +} + +type PutRolePolicyInput struct { + RoleName string + PolicyName string + PolicyDocument string +} + +type ListRolePoliciesInput struct { + RoleName string + Marker string + MaxItems int32 +} + +type ListRolePoliciesOutput struct { + PolicyNames []string + IsTruncated bool + Marker string +} + +type ListRoleTagsInput struct { + RoleName string + Marker string + MaxItems int32 +} + +type ListOIDCProvidersOutput struct { + Providers []types.OpenIDConnectProviderListEntry +} + +type ListOIDCProviderTagsInput struct { + Arn string + Marker string + MaxItems int32 +} + +// mergeTags applies the tag actions' merge semantics to existing: an +// incoming tag replaces the existing tag whose key matches under keyCase — +// taking over its position and its key's casing — and any remaining +// incoming tag is appended in the order supplied. AWS caps the merged +// total, not the request, so replacing a tag on a resource already at the +// cap is allowed. +func mergeTags(existing, incoming []types.Tag, keyCase iamutil.TagKeyCase) ([]types.Tag, error) { + merged := slices.Clone(existing) + for _, tag := range incoming { + if idx := indexOfTagKey(merged, tag.Key, keyCase); idx >= 0 { + merged[idx] = tag + continue + } + if len(merged) >= MaxTagsPerResource { + return nil, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded) + } + merged = append(merged, tag) + } + return merged, nil +} + +// removeTags applies the untag actions' removal semantics to existing: +// every tag whose key matches one of tagKeys under keyCase is dropped, and +// a key naming no existing tag is ignored rather than reported. +func removeTags(existing []types.Tag, tagKeys []string, keyCase iamutil.TagKeyCase) []types.Tag { + return slices.DeleteFunc(slices.Clone(existing), func(tag types.Tag) bool { + return slices.ContainsFunc(tagKeys, func(key string) bool { + return keyCase.Equal(key, tag.Key) + }) + }) +} + +func indexOfTagKey(tags []types.Tag, key string, keyCase iamutil.TagKeyCase) int { + return slices.IndexFunc(tags, func(tag types.Tag) bool { + return keyCase.Equal(tag.Key, key) + }) +} + +// paginateTags sorts tags by key and applies the marker/maxItems window. +// AWS's own tag listings return tags in an unspecified order (its docs +// claim sorted by key; live responses are not), so this sorts by key: a +// stable order is what makes a Marker meaningful, and it's the order the +// documentation promises. +func paginateTags(tags []types.Tag, marker string, maxItems int32, keyCase iamutil.TagKeyCase) *ListTagsOutput { + sorted := slices.Clone(tags) + slices.SortFunc(sorted, func(a, b types.Tag) int { + return strings.Compare(a.Key, b.Key) + }) + + if marker != "" { + start := len(sorted) + if idx := indexOfTagKey(sorted, marker, keyCase); idx >= 0 { + start = idx + 1 + } + sorted = sorted[start:] + } + + limit := len(sorted) + if maxItems > 0 && int(maxItems) < limit { + limit = int(maxItems) + } + + out := &ListTagsOutput{Tags: sorted[:limit]} + if limit < len(sorted) { + out.IsTruncated = true + out.Marker = out.Tags[limit-1].Key + } + + return out +} + +func unwrapAPIError(err error) error { + var apiErr iamerr.APIError + if errors.As(err, &apiErr) { + return apiErr + } + + return err +} diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index 27acfc3a..3f68e1f7 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -347,6 +347,67 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t return cloneUser(updated), nil } +func (s *InternalStore) TagUser(_ context.Context, userName string, tags []types.Tag) error { + return s.updateUserTags(userName, func(user *types.User) error { + merged, err := mergeTags(user.Tags, tags, iamutil.TagKeysFolded) + if err != nil { + return err + } + user.Tags = merged + return nil + }) +} + +func (s *InternalStore) UntagUser(_ context.Context, userName string, tagKeys []string) error { + return s.updateUserTags(userName, func(user *types.User) error { + user.Tags = removeTags(user.Tags, tagKeys, iamutil.TagKeysFolded) + return nil + }) +} + +// updateUserTags applies mutate to userName's stored record and writes it +// back under the store lock. +func (s *InternalStore) updateUserTags(userName string, mutate func(*types.User) error) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, user, ok := lookupUser(conf, userName) + if !ok { + return nil, iamerr.NoSuchEntityUser(userName) + } + if err := mutate(&user); err != nil { + return nil, err + } + + conf.Users[canonical] = user + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput) (*ListTagsOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, user, ok := lookupUser(conf, input.UserName) + if !ok { + return nil, iamerr.NoSuchEntityUser(input.UserName) + } + + return paginateTags(user.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil +} + func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { s.Lock() defer s.Unlock() @@ -896,6 +957,67 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs return cloneRole(updated), nil } +func (s *InternalStore) TagRole(_ context.Context, roleName string, tags []types.Tag) error { + return s.updateRoleTags(roleName, func(role *types.Role) error { + merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded) + if err != nil { + return err + } + role.Tags = merged + return nil + }) +} + +func (s *InternalStore) UntagRole(_ context.Context, roleName string, tagKeys []string) error { + return s.updateRoleTags(roleName, func(role *types.Role) error { + role.Tags = removeTags(role.Tags, tagKeys, iamutil.TagKeysFolded) + return nil + }) +} + +// updateRoleTags applies mutate to roleName's stored record and writes it +// back under the store lock. +func (s *InternalStore) updateRoleTags(roleName string, mutate func(*types.Role) error) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + + canonical, role, ok := lookupRole(conf, roleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(roleName) + } + if err := mutate(&role); err != nil { + return nil, err + } + + conf.Roles[canonical] = role + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListRoleTags(_ context.Context, input ListRoleTagsInput) (*ListTagsOutput, error) { + s.RLock() + defer s.RUnlock() + + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + _, role, ok := lookupRole(conf, input.RoleName) + if !ok { + return nil, iamerr.NoSuchEntityRole(input.RoleName) + } + + return paginateTags(role.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil +} + func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error { s.Lock() defer s.Unlock() @@ -1235,6 +1357,74 @@ func (s *InternalStore) UpdateOIDCProviderThumbprint(_ context.Context, arn stri return unwrapAPIError(err) } +func (s *InternalStore) TagOIDCProvider(_ context.Context, arn string, tags []types.Tag) error { + return s.updateOIDCProviderTags(arn, func(provider *types.OIDCProvider) error { + merged, err := mergeTags(provider.Tags, tags, iamutil.TagKeysExact) + if err != nil { + return err + } + provider.Tags = merged + return nil + }) +} + +func (s *InternalStore) UntagOIDCProvider(_ context.Context, arn string, tagKeys []string) error { + return s.updateOIDCProviderTags(arn, func(provider *types.OIDCProvider) error { + provider.Tags = removeTags(provider.Tags, tagKeys, iamutil.TagKeysExact) + return nil + }) +} + +// updateOIDCProviderTags applies mutate to arn's stored record and writes +// it back under the store lock. +func (s *InternalStore) updateOIDCProviderTags(arn string, mutate func(*types.OIDCProvider) error) error { + s.Lock() + defer s.Unlock() + + err := s.engine.StoreIAM(func(data []byte) ([]byte, error) { + conf, err := s.engine.ParseIAM(data) + if err != nil { + return nil, err + } + url, err := iamutil.ParseOIDCProviderArn(arn) + if err != nil { + return nil, err + } + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderDelete(arn) + } + if err := mutate(&provider); err != nil { + return nil, err + } + + conf.OIDCProviders[url] = provider + return json.Marshal(conf) + }) + return unwrapAPIError(err) +} + +func (s *InternalStore) ListOIDCProviderTags(_ context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error) { + s.RLock() + defer s.RUnlock() + + url, err := iamutil.ParseOIDCProviderArn(input.Arn) + if err != nil { + return nil, err + } + conf, err := s.engine.GetIAM() + if err != nil { + return nil, err + } + + provider, ok := conf.OIDCProviders[url] + if !ok { + return nil, iamerr.NoSuchEntityOIDCProviderDelete(input.Arn) + } + + return paginateTags(provider.Tags, input.Marker, input.MaxItems, iamutil.TagKeysExact), nil +} + func (s *InternalStore) CreateSession(_ context.Context, session types.Session) (*types.Session, error) { s.Lock() defer s.Unlock() diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index 28aa5a8d..801422e7 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -16,167 +16,13 @@ package storage import ( "context" - "errors" "fmt" "strings" "time" - "github.com/versity/versitygw/iamapi/iamerr" "github.com/versity/versitygw/iamapi/types" ) -// MaxAccessKeysPerUser is the maximum number of access keys a single IAM -// user may hold at once, matching the AWS IAM quota. -const MaxAccessKeysPerUser = 2 - -// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of -// all of a single IAM user's inline policy documents combined -const MaxInlinePolicyBytesPerUser = 2048 - -// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of -// all of a single IAM role's inline policy documents combined -const MaxInlinePolicyBytesPerRole = 10240 - -// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single -// OIDC provider may hold at once -const MaxClientIDsPerOIDCProvider = 100 - -// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a -// single account may hold -const MaxOIDCProvidersPerAccount = 100 - -// MaxActiveSessionsPerRole bounds how many currently-unexpired -// AssumeRoleWithWebIdentity sessions a single role may have at once. -// AWS manages and rate-limits STS as a hosted service with no -// customer-visible equivalent quota to match for fidelity; this exists -// purely as local resource protection, since without it a single valid -// federated token can be replayed indefinitely to grow the session -// store — every InternalStore rewrite, or Vault KV path/metadata entry — -// without bound. Chosen generously enough to not constrain any legitimate -// workload's concurrent session count. -// -// A var, not a const, so tests can temporarily lower it rather than paying -// the cost of actually creating 1000 sessions to exercise the cap. -var MaxActiveSessionsPerRole = 1000 - -var ( - ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists") - ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists") - ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists") - // ErrSessionNotFound is returned by GetSession when accessKeyID names no - // session, or names one whose Expiration has already passed. - ErrSessionNotFound = errors.New("iamapi: session not found") -) - -type ListUsersInput struct { - PathPrefix string - Marker string - MaxItems int32 -} - -type ListUsersOutput struct { - Users []types.User - IsTruncated bool - Marker string -} - -type UpdateUserInput struct { - UserName string - NewPath string - NewUserName string - NewArn string -} - -type CreateAccessKeyInput struct { - UserName string - AccessKeyID string - SecretAccessKey string - Status string - CreateDate time.Time -} - -type UpdateAccessKeyInput struct { - UserName string - AccessKeyID string - Status string -} - -type ListAccessKeysInput struct { - UserName string - Marker string - MaxItems int32 -} - -type ListAccessKeysOutput struct { - AccessKeys []types.AccessKeyMetadata - IsTruncated bool - Marker string -} - -type GetAccessKeyLastUsedOutput struct { - UserName string - LastUsedDate time.Time - ServiceName string - Region string -} - -type PutUserPolicyInput struct { - UserName string - PolicyName string - PolicyDocument string -} - -type ListUserPoliciesInput struct { - UserName string - Marker string - MaxItems int32 -} - -type ListUserPoliciesOutput struct { - PolicyNames []string - IsTruncated bool - Marker string -} - -type ListRolesInput struct { - PathPrefix string - Marker string - MaxItems int32 -} - -type ListRolesOutput struct { - Roles []types.Role - IsTruncated bool - Marker string -} - -type UpdateAssumeRolePolicyInput struct { - RoleName string - PolicyDocument string -} - -type PutRolePolicyInput struct { - RoleName string - PolicyName string - PolicyDocument string -} - -type ListRolePoliciesInput struct { - RoleName string - Marker string - MaxItems int32 -} - -type ListRolePoliciesOutput struct { - PolicyNames []string - IsTruncated bool - Marker string -} - -type ListOIDCProvidersOutput struct { - Providers []types.OpenIDConnectProviderListEntry -} - // Storer is the IAM API storage backend contract. type Storer interface { CreateUser(ctx context.Context, user types.User) (*types.User, error) @@ -186,6 +32,10 @@ type Storer interface { ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) + TagUser(ctx context.Context, userName string, tags []types.Tag) error + UntagUser(ctx context.Context, userName string, tagKeys []string) error + ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListTagsOutput, error) + CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error DeleteAccessKey(ctx context.Context, username, accessKeyID string) error @@ -209,6 +59,10 @@ type Storer interface { DeleteRole(ctx context.Context, roleName string) error UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) + TagRole(ctx context.Context, roleName string, tags []types.Tag) error + UntagRole(ctx context.Context, roleName string, tagKeys []string) error + ListRoleTags(ctx context.Context, input ListRoleTagsInput) (*ListTagsOutput, error) + PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error @@ -223,19 +77,14 @@ type Storer interface { RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error + TagOIDCProvider(ctx context.Context, arn string, tags []types.Tag) error + UntagOIDCProvider(ctx context.Context, arn string, tagKeys []string) error + ListOIDCProviderTags(ctx context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error) + CreateSession(ctx context.Context, session types.Session) (*types.Session, error) GetSession(ctx context.Context, accessKeyID string) (*types.Session, error) } -func unwrapAPIError(err error) error { - var apiErr iamerr.APIError - if errors.As(err, &apiErr) { - return apiErr - } - - return err -} - type Config struct { Dir string Vault VaultConfig diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index e5d10da1..43c377fe 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -643,6 +643,35 @@ func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate fu return nil, iamerr.ConcurrentModification() } +func (s *VaultStore) TagUser(ctx context.Context, userName string, tags []types.Tag) error { + _, err := s.withUserCAS(ctx, userName, func(user *types.User) error { + merged, err := mergeTags(user.Tags, tags, iamutil.TagKeysFolded) + if err != nil { + return err + } + user.Tags = merged + return nil + }) + return err +} + +func (s *VaultStore) UntagUser(ctx context.Context, userName string, tagKeys []string) error { + _, err := s.withUserCAS(ctx, userName, func(user *types.User) error { + user.Tags = removeTags(user.Tags, tagKeys, iamutil.TagKeysFolded) + return nil + }) + return err +} + +func (s *VaultStore) ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListTagsOutput, error) { + user, err := s.GetUser(ctx, input.UserName) + if err != nil { + return nil, err + } + + return paginateTags(user.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil +} + func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { var created types.AccessKey if _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error { @@ -1173,6 +1202,35 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss }) } +func (s *VaultStore) TagRole(ctx context.Context, roleName string, tags []types.Tag) error { + _, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error { + merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded) + if err != nil { + return err + } + role.Tags = merged + return nil + }) + return err +} + +func (s *VaultStore) UntagRole(ctx context.Context, roleName string, tagKeys []string) error { + _, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error { + role.Tags = removeTags(role.Tags, tagKeys, iamutil.TagKeysFolded) + return nil + }) + return err +} + +func (s *VaultStore) ListRoleTags(ctx context.Context, input ListRoleTagsInput) (*ListTagsOutput, error) { + role, err := s.GetRole(ctx, input.RoleName) + if err != nil { + return nil, err + } + + return paginateTags(role.Tags, input.Marker, input.MaxItems, iamutil.TagKeysFolded), nil +} + func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error { _, err := s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error { newTotal := len(input.PolicyDocument) @@ -1469,7 +1527,7 @@ func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCPr } func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) { - provider, _, err := s.readOIDCProviderVersion(arn) + provider, _, err := s.readOIDCProviderVersion(arn, iamerr.NoSuchEntityOIDCProviderGet) return provider, err } @@ -1477,8 +1535,10 @@ func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDC // readUserVersion/readRoleVersion: it additionally returns the KV version // the record was read at, so a mutation can write back with a matching CAS // value instead of racing on a blind delete-then-recreate (see -// replaceOIDCProvider). -func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, int32, error) { +// replaceOIDCProvider). notFound supplies the NoSuchEntity error the +// calling action reports, which the tagging actions word differently from +// the rest. +func (s *VaultStore) readOIDCProviderVersion(arn string, notFound func(string) iamerr.Error) (*types.OIDCProvider, int32, error) { url, err := iamutil.ParseOIDCProviderArn(arn) if err != nil { return nil, 0, err @@ -1489,7 +1549,7 @@ func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, i resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) + return nil, 0, notFound(arn) } if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil { return nil, 0, reauthErr @@ -1497,7 +1557,7 @@ func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, i resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...) if err != nil { if vault.IsErrorStatus(err, http.StatusNotFound) { - return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn) + return nil, 0, notFound(arn) } return nil, 0, err } @@ -1599,7 +1659,7 @@ func (s *VaultStore) deleteOIDCProviderByURL(url string) error { } func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error { - return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderGet, func(provider *types.OIDCProvider) error { if slices.Contains(provider.ClientIDList, clientID) { return nil } @@ -1612,7 +1672,7 @@ func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientI } func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error { - return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderGet, func(provider *types.OIDCProvider) error { idx := slices.Index(provider.ClientIDList, clientID) if idx == -1 { return nil @@ -1623,12 +1683,39 @@ func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, cl } func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error { - return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error { + return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderGet, func(provider *types.OIDCProvider) error { provider.ThumbprintList = thumbprints return nil }) } +func (s *VaultStore) TagOIDCProvider(ctx context.Context, arn string, tags []types.Tag) error { + return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderDelete, func(provider *types.OIDCProvider) error { + merged, err := mergeTags(provider.Tags, tags, iamutil.TagKeysExact) + if err != nil { + return err + } + provider.Tags = merged + return nil + }) +} + +func (s *VaultStore) UntagOIDCProvider(ctx context.Context, arn string, tagKeys []string) error { + return s.withOIDCProviderCAS(ctx, arn, iamerr.NoSuchEntityOIDCProviderDelete, func(provider *types.OIDCProvider) error { + provider.Tags = removeTags(provider.Tags, tagKeys, iamutil.TagKeysExact) + return nil + }) +} + +func (s *VaultStore) ListOIDCProviderTags(_ context.Context, input ListOIDCProviderTagsInput) (*ListTagsOutput, error) { + provider, _, err := s.readOIDCProviderVersion(input.Arn, iamerr.NoSuchEntityOIDCProviderDelete) + if err != nil { + return nil, err + } + + return paginateTags(provider.Tags, input.Marker, input.MaxItems, iamutil.TagKeysExact), nil +} + // replaceOIDCProvider overwrites the stored document for provider.Url using // a version-checked (CAS) write tied to readVersion, instead of an // unconditional delete-then-recreate — see replaceUser for the rationale. @@ -1665,9 +1752,9 @@ func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OID } // withOIDCProviderCAS is withUserCAS's counterpart for OIDC providers. -func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, mutate func(*types.OIDCProvider) error) error { +func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, notFound func(string) iamerr.Error, mutate func(*types.OIDCProvider) error) error { for range maxCASRetries { - provider, version, err := s.readOIDCProviderVersion(arn) + provider, version, err := s.readOIDCProviderVersion(arn, notFound) if err != nil { return err } diff --git a/iamapi/types/oidc.go b/iamapi/types/oidc.go index d8ee2e84..201a8d4f 100644 --- a/iamapi/types/oidc.go +++ b/iamapi/types/oidc.go @@ -127,3 +127,37 @@ type UpdateOpenIDConnectProviderThumbprintResponse struct { func (r *UpdateOpenIDConnectProviderThumbprintResponse) SetRequestID(requestID string) { r.ResponseMetadata.RequestID = requestID } + +type TagOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *TagOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UntagOpenIDConnectProviderResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagOpenIDConnectProviderResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UntagOpenIDConnectProviderResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListOpenIDConnectProviderTagsResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProviderTagsResponse"` + Result ListOpenIDConnectProviderTagsResult `xml:"ListOpenIDConnectProviderTagsResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListOpenIDConnectProviderTagsResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListOpenIDConnectProviderTagsResult struct { + Tags Tags + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/iamapi/types/role.go b/iamapi/types/role.go index fada0857..2e2bd755 100644 --- a/iamapi/types/role.go +++ b/iamapi/types/role.go @@ -111,3 +111,37 @@ type UpdateAssumeRolePolicyResponse struct { func (r *UpdateAssumeRolePolicyResponse) SetRequestID(requestID string) { r.ResponseMetadata.RequestID = requestID } + +type TagRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagRoleResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *TagRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UntagRoleResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagRoleResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UntagRoleResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRoleTagsResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListRoleTagsResponse"` + Result ListRoleTagsResult `xml:"ListRoleTagsResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListRoleTagsResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListRoleTagsResult struct { + Tags Tags + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/iamapi/types/user.go b/iamapi/types/user.go index f8f4a91f..76dca53f 100644 --- a/iamapi/types/user.go +++ b/iamapi/types/user.go @@ -113,3 +113,44 @@ type Tag struct { Key string Value string } + +// Tags is the XML wrapper for a tag list, rendering as +// — and, when empty, as rather +// than being omitted, matching AWS's response for an untagged user. +type Tags struct { + Members []Tag `xml:"member"` +} + +type TagUserResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagUserResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *TagUserResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type UntagUserResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagUserResponse"` + ResponseMetadata ResponseMetadata +} + +func (r *UntagUserResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserTagsResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserTagsResponse"` + Result ListUserTagsResult `xml:"ListUserTagsResult"` + ResponseMetadata ResponseMetadata +} + +func (r *ListUserTagsResponse) SetRequestID(requestID string) { + r.ResponseMetadata.RequestID = requestID +} + +type ListUserTagsResult struct { + Tags Tags + IsTruncated bool + Marker string `xml:",omitempty"` +} diff --git a/tests/integration/PutObjectRetention.go b/tests/integration/PutObjectRetention.go index 758f6576..03d43692 100644 --- a/tests/integration/PutObjectRetention.go +++ b/tests/integration/PutObjectRetention.go @@ -479,31 +479,16 @@ func PutObjectRetention_shorten_governance_with_bypass(s *S3Conf) error { func PutObjectRetention_shorten_compliance_denied(s *S3Conf) error { testName := "PutObjectRetention_shorten_compliance_denied" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - date := time.Now().Add(complianceTestRetention) obj := "my-obj" _, err := putObjects(s3client, []string{obj}, bucket) if err != nil { return err } - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ - Bucket: &bucket, - Key: &obj, - Retention: &types.ObjectLockRetention{ - Mode: types.ObjectLockRetentionModeCompliance, - RetainUntilDate: &date, - }, - }) - cancel() - if err != nil { - return err - } - policy := genPolicyDoc("Allow", fmt.Sprintf(`"%v"`, s.awsID), `["s3:BypassGovernanceRetention"]`, fmt.Sprintf(`"arn:aws:s3:::%v/*"`, bucket)) bypass := true - ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ Bucket: &bucket, Policy: &policy, @@ -513,6 +498,27 @@ func PutObjectRetention_shorten_compliance_denied(s *S3Conf) error { return err } + // The COMPLIANCE lock goes on last, after the slow setup above rather + // than before it, so the whole complianceTestRetention budget is left + // for the two attempts below. Started any earlier, a slow object upload + // can consume the entire window and leave an already expired retention + // with nothing to shorten. + date := time.Now().Add(complianceTestRetention) + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{ + Bucket: &bucket, + Key: &obj, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: &date, + }, + }) + cancel() + if err != nil { + return err + } + // Recomputed fresh right before each request below, rather than once // up front: the server rejects a RetainUntilDate that has already // passed (InvalidArgument) before it ever gets to the object-lock diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 3e8a05b7..66b88ba9 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1196,6 +1196,51 @@ func TestIAMUpdateUser(ts *TestState) { ts.Run(IAMUpdateUser_success) } +func TestIAMTagUser(ts *TestState) { + ts.Run(IAMTagUser_missing_user_name) + ts.Run(IAMTagUser_invalid_user_name) + ts.Run(IAMTagUser_user_name_too_long) + ts.Run(IAMTagUser_missing_tags) + ts.Run(IAMTagUser_missing_tag_key) + ts.Run(IAMTagUser_missing_tag_value) + ts.Run(IAMTagUser_empty_tag_key) + ts.Run(IAMTagUser_tag_key_too_long) + ts.Run(IAMTagUser_invalid_tag_key) + ts.Run(IAMTagUser_tag_value_too_long) + ts.Run(IAMTagUser_invalid_tag_value) + ts.Run(IAMTagUser_duplicate_tag_keys) + ts.Run(IAMTagUser_too_many_tags) + ts.Run(IAMTagUser_non_existing_user) + ts.Run(IAMTagUser_tag_limit_exceeded) + ts.Run(IAMTagUser_success) + ts.Run(IAMTagUser_overwrites_existing_tag) +} + +func TestIAMUntagUser(ts *TestState) { + ts.Run(IAMUntagUser_missing_user_name) + ts.Run(IAMUntagUser_invalid_user_name) + ts.Run(IAMUntagUser_user_name_too_long) + ts.Run(IAMUntagUser_missing_tag_keys) + ts.Run(IAMUntagUser_invalid_tag_key) + ts.Run(IAMUntagUser_too_many_tag_keys) + ts.Run(IAMUntagUser_non_existing_user) + ts.Run(IAMUntagUser_success) + ts.Run(IAMUntagUser_removal_is_idempotent) + ts.Run(IAMUntagUser_case_insensitive_key) +} + +func TestIAMListUserTags(ts *TestState) { + ts.Run(IAMListUserTags_missing_user_name) + ts.Run(IAMListUserTags_invalid_user_name) + ts.Run(IAMListUserTags_user_name_too_long) + ts.Run(IAMListUserTags_invalid_max_items) + ts.Run(IAMListUserTags_invalid_max_items_format) + ts.Run(IAMListUserTags_non_existing_user) + ts.Run(IAMListUserTags_empty_result) + ts.Run(IAMListUserTags_success) + ts.Run(IAMListUserTags_pagination) +} + func TestIAMCreateAccessKey(ts *TestState) { ts.Run(IAMCreateAccessKey_missing_user_name) ts.Run(IAMCreateAccessKey_invalid_user_name) @@ -1358,6 +1403,53 @@ func TestIAMUpdateAssumeRolePolicy(ts *TestState) { ts.Run(IAMUpdateAssumeRolePolicy_trust_policy_document_grammar) } +func TestIAMTagRole(ts *TestState) { + ts.Run(IAMTagRole_missing_role_name) + ts.Run(IAMTagRole_invalid_role_name) + ts.Run(IAMTagRole_role_name_too_long) + ts.Run(IAMTagRole_missing_tags) + ts.Run(IAMTagRole_missing_tag_key) + ts.Run(IAMTagRole_missing_tag_value) + ts.Run(IAMTagRole_empty_tag_key) + ts.Run(IAMTagRole_tag_key_too_long) + ts.Run(IAMTagRole_invalid_tag_key) + ts.Run(IAMTagRole_tag_value_too_long) + ts.Run(IAMTagRole_invalid_tag_value) + ts.Run(IAMTagRole_duplicate_tag_keys) + ts.Run(IAMTagRole_too_many_tags) + ts.Run(IAMTagRole_non_existing_role) + ts.Run(IAMTagRole_tag_limit_exceeded) + ts.Run(IAMTagRole_success) + ts.Run(IAMTagRole_overwrites_existing_tag) + ts.Run(IAMTagRole_isolated_from_same_named_user) +} + +func TestIAMUntagRole(ts *TestState) { + ts.Run(IAMUntagRole_missing_role_name) + ts.Run(IAMUntagRole_invalid_role_name) + ts.Run(IAMUntagRole_role_name_too_long) + ts.Run(IAMUntagRole_missing_tag_keys) + ts.Run(IAMUntagRole_invalid_tag_key) + ts.Run(IAMUntagRole_too_many_tag_keys) + ts.Run(IAMUntagRole_non_existing_role) + ts.Run(IAMUntagRole_success) + ts.Run(IAMUntagRole_removal_is_idempotent) + ts.Run(IAMUntagRole_case_insensitive_key) + ts.Run(IAMUntagRole_removes_only_named_keys) +} + +func TestIAMListRoleTags(ts *TestState) { + ts.Run(IAMListRoleTags_missing_role_name) + ts.Run(IAMListRoleTags_invalid_role_name) + ts.Run(IAMListRoleTags_role_name_too_long) + ts.Run(IAMListRoleTags_invalid_max_items) + ts.Run(IAMListRoleTags_invalid_max_items_format) + ts.Run(IAMListRoleTags_non_existing_role) + ts.Run(IAMListRoleTags_empty_result) + ts.Run(IAMListRoleTags_success) + ts.Run(IAMListRoleTags_pagination) +} + func TestIAMPutRolePolicy(ts *TestState) { ts.Run(IAMPutRolePolicy_missing_role_name) ts.Run(IAMPutRolePolicy_missing_policy_name) @@ -1463,6 +1555,47 @@ func TestIAMUpdateOpenIDConnectProviderThumbprint(ts *TestState) { ts.Run(IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints) } +func TestIAMTagOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMTagOpenIDConnectProvider_missing_arn) + ts.Run(IAMTagOpenIDConnectProvider_invalid_arn) + ts.Run(IAMTagOpenIDConnectProvider_missing_tags) + ts.Run(IAMTagOpenIDConnectProvider_missing_tag_key) + ts.Run(IAMTagOpenIDConnectProvider_missing_tag_value) + ts.Run(IAMTagOpenIDConnectProvider_invalid_tags) + ts.Run(IAMTagOpenIDConnectProvider_duplicate_tag_keys) + ts.Run(IAMTagOpenIDConnectProvider_too_many_tags) + ts.Run(IAMTagOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup) + ts.Run(IAMTagOpenIDConnectProvider_tag_limit_exceeded) + ts.Run(IAMTagOpenIDConnectProvider_success) + ts.Run(IAMTagOpenIDConnectProvider_overwrites_existing_tag) + ts.Run(IAMTagOpenIDConnectProvider_isolated_per_provider) +} + +func TestIAMUntagOpenIDConnectProvider(ts *TestState) { + ts.Run(IAMUntagOpenIDConnectProvider_missing_arn) + ts.Run(IAMUntagOpenIDConnectProvider_invalid_arn) + ts.Run(IAMUntagOpenIDConnectProvider_missing_tag_keys) + ts.Run(IAMUntagOpenIDConnectProvider_invalid_tag_key) + ts.Run(IAMUntagOpenIDConnectProvider_too_many_tag_keys) + ts.Run(IAMUntagOpenIDConnectProvider_non_existing_provider) + ts.Run(IAMUntagOpenIDConnectProvider_success) + ts.Run(IAMUntagOpenIDConnectProvider_removal_is_idempotent) + ts.Run(IAMUntagOpenIDConnectProvider_case_sensitive_key) + ts.Run(IAMUntagOpenIDConnectProvider_removes_only_named_keys) +} + +func TestIAMListOpenIDConnectProviderTags(ts *TestState) { + ts.Run(IAMListOpenIDConnectProviderTags_missing_arn) + ts.Run(IAMListOpenIDConnectProviderTags_invalid_arn) + ts.Run(IAMListOpenIDConnectProviderTags_invalid_max_items) + ts.Run(IAMListOpenIDConnectProviderTags_invalid_max_items_format) + ts.Run(IAMListOpenIDConnectProviderTags_non_existing_provider) + ts.Run(IAMListOpenIDConnectProviderTags_empty_result) + ts.Run(IAMListOpenIDConnectProviderTags_success) + ts.Run(IAMListOpenIDConnectProviderTags_pagination) +} + func TestIAMAssumeRoleWithWebIdentity(ts *TestState) { ts.Run(IAMAssumeRoleWithWebIdentity_missing_role_arn) ts.Run(IAMAssumeRoleWithWebIdentity_role_arn_too_short) @@ -1637,6 +1770,9 @@ func TestIAM(ts *TestState) { TestIAMListUsers(ts) TestIAMDeleteUser(ts) TestIAMUpdateUser(ts) + TestIAMTagUser(ts) + TestIAMUntagUser(ts) + TestIAMListUserTags(ts) TestIAMCreateAccessKey(ts) TestIAMUpdateAccessKey(ts) TestIAMDeleteAccessKey(ts) @@ -1651,6 +1787,9 @@ func TestIAM(ts *TestState) { TestIAMListRoles(ts) TestIAMDeleteRole(ts) TestIAMUpdateAssumeRolePolicy(ts) + TestIAMTagRole(ts) + TestIAMUntagRole(ts) + TestIAMListRoleTags(ts) TestIAMPutRolePolicy(ts) TestIAMGetRolePolicy(ts) TestIAMDeleteRolePolicy(ts) @@ -1662,6 +1801,9 @@ func TestIAM(ts *TestState) { TestIAMAddClientIDToOpenIDConnectProvider(ts) TestIAMRemoveClientIDFromOpenIDConnectProvider(ts) TestIAMUpdateOpenIDConnectProviderThumbprint(ts) + TestIAMTagOpenIDConnectProvider(ts) + TestIAMUntagOpenIDConnectProvider(ts) + TestIAMListOpenIDConnectProviderTags(ts) TestIAMAssumeRoleWithWebIdentity(ts) TestIAMGetCallerIdentity(ts) TestIAMAccessControl(ts) @@ -2127,6 +2269,42 @@ func GetIntTests() IntTests { "IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path, "IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists, "IAMUpdateUser_success": IAMUpdateUser_success, + "IAMTagUser_missing_user_name": IAMTagUser_missing_user_name, + "IAMTagUser_invalid_user_name": IAMTagUser_invalid_user_name, + "IAMTagUser_user_name_too_long": IAMTagUser_user_name_too_long, + "IAMTagUser_missing_tags": IAMTagUser_missing_tags, + "IAMTagUser_missing_tag_key": IAMTagUser_missing_tag_key, + "IAMTagUser_missing_tag_value": IAMTagUser_missing_tag_value, + "IAMTagUser_empty_tag_key": IAMTagUser_empty_tag_key, + "IAMTagUser_tag_key_too_long": IAMTagUser_tag_key_too_long, + "IAMTagUser_invalid_tag_key": IAMTagUser_invalid_tag_key, + "IAMTagUser_tag_value_too_long": IAMTagUser_tag_value_too_long, + "IAMTagUser_invalid_tag_value": IAMTagUser_invalid_tag_value, + "IAMTagUser_duplicate_tag_keys": IAMTagUser_duplicate_tag_keys, + "IAMTagUser_too_many_tags": IAMTagUser_too_many_tags, + "IAMTagUser_non_existing_user": IAMTagUser_non_existing_user, + "IAMTagUser_tag_limit_exceeded": IAMTagUser_tag_limit_exceeded, + "IAMTagUser_success": IAMTagUser_success, + "IAMTagUser_overwrites_existing_tag": IAMTagUser_overwrites_existing_tag, + "IAMUntagUser_missing_user_name": IAMUntagUser_missing_user_name, + "IAMUntagUser_invalid_user_name": IAMUntagUser_invalid_user_name, + "IAMUntagUser_user_name_too_long": IAMUntagUser_user_name_too_long, + "IAMUntagUser_missing_tag_keys": IAMUntagUser_missing_tag_keys, + "IAMUntagUser_invalid_tag_key": IAMUntagUser_invalid_tag_key, + "IAMUntagUser_too_many_tag_keys": IAMUntagUser_too_many_tag_keys, + "IAMUntagUser_non_existing_user": IAMUntagUser_non_existing_user, + "IAMUntagUser_success": IAMUntagUser_success, + "IAMUntagUser_removal_is_idempotent": IAMUntagUser_removal_is_idempotent, + "IAMUntagUser_case_insensitive_key": IAMUntagUser_case_insensitive_key, + "IAMListUserTags_missing_user_name": IAMListUserTags_missing_user_name, + "IAMListUserTags_invalid_user_name": IAMListUserTags_invalid_user_name, + "IAMListUserTags_user_name_too_long": IAMListUserTags_user_name_too_long, + "IAMListUserTags_invalid_max_items": IAMListUserTags_invalid_max_items, + "IAMListUserTags_invalid_max_items_format": IAMListUserTags_invalid_max_items_format, + "IAMListUserTags_non_existing_user": IAMListUserTags_non_existing_user, + "IAMListUserTags_empty_result": IAMListUserTags_empty_result, + "IAMListUserTags_success": IAMListUserTags_success, + "IAMListUserTags_pagination": IAMListUserTags_pagination, "IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name, "IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name, "IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name, @@ -2247,6 +2425,44 @@ func GetIntTests() IntTests { "IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded": IAMUpdateAssumeRolePolicy_trust_policy_size_limit_exceeded, "IAMUpdateAssumeRolePolicy_success": IAMUpdateAssumeRolePolicy_success, "IAMUpdateAssumeRolePolicy_trust_policy_document_grammar": IAMUpdateAssumeRolePolicy_trust_policy_document_grammar, + "IAMTagRole_missing_role_name": IAMTagRole_missing_role_name, + "IAMTagRole_invalid_role_name": IAMTagRole_invalid_role_name, + "IAMTagRole_role_name_too_long": IAMTagRole_role_name_too_long, + "IAMTagRole_missing_tags": IAMTagRole_missing_tags, + "IAMTagRole_missing_tag_key": IAMTagRole_missing_tag_key, + "IAMTagRole_missing_tag_value": IAMTagRole_missing_tag_value, + "IAMTagRole_empty_tag_key": IAMTagRole_empty_tag_key, + "IAMTagRole_tag_key_too_long": IAMTagRole_tag_key_too_long, + "IAMTagRole_invalid_tag_key": IAMTagRole_invalid_tag_key, + "IAMTagRole_tag_value_too_long": IAMTagRole_tag_value_too_long, + "IAMTagRole_invalid_tag_value": IAMTagRole_invalid_tag_value, + "IAMTagRole_duplicate_tag_keys": IAMTagRole_duplicate_tag_keys, + "IAMTagRole_too_many_tags": IAMTagRole_too_many_tags, + "IAMTagRole_non_existing_role": IAMTagRole_non_existing_role, + "IAMTagRole_tag_limit_exceeded": IAMTagRole_tag_limit_exceeded, + "IAMTagRole_success": IAMTagRole_success, + "IAMTagRole_overwrites_existing_tag": IAMTagRole_overwrites_existing_tag, + "IAMTagRole_isolated_from_same_named_user": IAMTagRole_isolated_from_same_named_user, + "IAMUntagRole_missing_role_name": IAMUntagRole_missing_role_name, + "IAMUntagRole_invalid_role_name": IAMUntagRole_invalid_role_name, + "IAMUntagRole_role_name_too_long": IAMUntagRole_role_name_too_long, + "IAMUntagRole_missing_tag_keys": IAMUntagRole_missing_tag_keys, + "IAMUntagRole_invalid_tag_key": IAMUntagRole_invalid_tag_key, + "IAMUntagRole_too_many_tag_keys": IAMUntagRole_too_many_tag_keys, + "IAMUntagRole_non_existing_role": IAMUntagRole_non_existing_role, + "IAMUntagRole_success": IAMUntagRole_success, + "IAMUntagRole_removal_is_idempotent": IAMUntagRole_removal_is_idempotent, + "IAMUntagRole_case_insensitive_key": IAMUntagRole_case_insensitive_key, + "IAMUntagRole_removes_only_named_keys": IAMUntagRole_removes_only_named_keys, + "IAMListRoleTags_missing_role_name": IAMListRoleTags_missing_role_name, + "IAMListRoleTags_invalid_role_name": IAMListRoleTags_invalid_role_name, + "IAMListRoleTags_role_name_too_long": IAMListRoleTags_role_name_too_long, + "IAMListRoleTags_invalid_max_items": IAMListRoleTags_invalid_max_items, + "IAMListRoleTags_invalid_max_items_format": IAMListRoleTags_invalid_max_items_format, + "IAMListRoleTags_non_existing_role": IAMListRoleTags_non_existing_role, + "IAMListRoleTags_empty_result": IAMListRoleTags_empty_result, + "IAMListRoleTags_success": IAMListRoleTags_success, + "IAMListRoleTags_pagination": IAMListRoleTags_pagination, "IAMPutRolePolicy_missing_role_name": IAMPutRolePolicy_missing_role_name, "IAMPutRolePolicy_missing_policy_name": IAMPutRolePolicy_missing_policy_name, "IAMPutRolePolicy_missing_policy_document": IAMPutRolePolicy_missing_policy_document, @@ -2319,6 +2535,38 @@ func GetIntTests() IntTests { "IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider": IAMUpdateOpenIDConnectProviderThumbprint_non_existing_provider, "IAMUpdateOpenIDConnectProviderThumbprint_success": IAMUpdateOpenIDConnectProviderThumbprint_success, "IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints": IAMUpdateOpenIDConnectProviderThumbprint_boundary_max_thumbprints, + "IAMTagOpenIDConnectProvider_missing_arn": IAMTagOpenIDConnectProvider_missing_arn, + "IAMTagOpenIDConnectProvider_invalid_arn": IAMTagOpenIDConnectProvider_invalid_arn, + "IAMTagOpenIDConnectProvider_missing_tags": IAMTagOpenIDConnectProvider_missing_tags, + "IAMTagOpenIDConnectProvider_missing_tag_key": IAMTagOpenIDConnectProvider_missing_tag_key, + "IAMTagOpenIDConnectProvider_missing_tag_value": IAMTagOpenIDConnectProvider_missing_tag_value, + "IAMTagOpenIDConnectProvider_invalid_tags": IAMTagOpenIDConnectProvider_invalid_tags, + "IAMTagOpenIDConnectProvider_duplicate_tag_keys": IAMTagOpenIDConnectProvider_duplicate_tag_keys, + "IAMTagOpenIDConnectProvider_too_many_tags": IAMTagOpenIDConnectProvider_too_many_tags, + "IAMTagOpenIDConnectProvider_non_existing_provider": IAMTagOpenIDConnectProvider_non_existing_provider, + "IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup": IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup, + "IAMTagOpenIDConnectProvider_tag_limit_exceeded": IAMTagOpenIDConnectProvider_tag_limit_exceeded, + "IAMTagOpenIDConnectProvider_success": IAMTagOpenIDConnectProvider_success, + "IAMTagOpenIDConnectProvider_overwrites_existing_tag": IAMTagOpenIDConnectProvider_overwrites_existing_tag, + "IAMTagOpenIDConnectProvider_isolated_per_provider": IAMTagOpenIDConnectProvider_isolated_per_provider, + "IAMUntagOpenIDConnectProvider_missing_arn": IAMUntagOpenIDConnectProvider_missing_arn, + "IAMUntagOpenIDConnectProvider_invalid_arn": IAMUntagOpenIDConnectProvider_invalid_arn, + "IAMUntagOpenIDConnectProvider_missing_tag_keys": IAMUntagOpenIDConnectProvider_missing_tag_keys, + "IAMUntagOpenIDConnectProvider_invalid_tag_key": IAMUntagOpenIDConnectProvider_invalid_tag_key, + "IAMUntagOpenIDConnectProvider_too_many_tag_keys": IAMUntagOpenIDConnectProvider_too_many_tag_keys, + "IAMUntagOpenIDConnectProvider_non_existing_provider": IAMUntagOpenIDConnectProvider_non_existing_provider, + "IAMUntagOpenIDConnectProvider_success": IAMUntagOpenIDConnectProvider_success, + "IAMUntagOpenIDConnectProvider_removal_is_idempotent": IAMUntagOpenIDConnectProvider_removal_is_idempotent, + "IAMUntagOpenIDConnectProvider_case_sensitive_key": IAMUntagOpenIDConnectProvider_case_sensitive_key, + "IAMUntagOpenIDConnectProvider_removes_only_named_keys": IAMUntagOpenIDConnectProvider_removes_only_named_keys, + "IAMListOpenIDConnectProviderTags_missing_arn": IAMListOpenIDConnectProviderTags_missing_arn, + "IAMListOpenIDConnectProviderTags_invalid_arn": IAMListOpenIDConnectProviderTags_invalid_arn, + "IAMListOpenIDConnectProviderTags_invalid_max_items": IAMListOpenIDConnectProviderTags_invalid_max_items, + "IAMListOpenIDConnectProviderTags_invalid_max_items_format": IAMListOpenIDConnectProviderTags_invalid_max_items_format, + "IAMListOpenIDConnectProviderTags_non_existing_provider": IAMListOpenIDConnectProviderTags_non_existing_provider, + "IAMListOpenIDConnectProviderTags_empty_result": IAMListOpenIDConnectProviderTags_empty_result, + "IAMListOpenIDConnectProviderTags_success": IAMListOpenIDConnectProviderTags_success, + "IAMListOpenIDConnectProviderTags_pagination": IAMListOpenIDConnectProviderTags_pagination, "IAMAssumeRoleWithWebIdentity_missing_role_arn": IAMAssumeRoleWithWebIdentity_missing_role_arn, "IAMAssumeRoleWithWebIdentity_role_arn_too_short": IAMAssumeRoleWithWebIdentity_role_arn_too_short, "IAMAssumeRoleWithWebIdentity_malformed_duration": IAMAssumeRoleWithWebIdentity_malformed_duration, diff --git a/tests/integration/iam_create_oidc_provider.go b/tests/integration/iam_create_oidc_provider.go index 66832078..cedcd89e 100644 --- a/tests/integration/iam_create_oidc_provider.go +++ b/tests/integration/iam_create_oidc_provider.go @@ -133,6 +133,9 @@ func IAMCreateOpenIDConnectProvider_invalid_thumbprint(s *S3Conf) error { }) } +// IAMCreateOpenIDConnectProvider_duplicate_tag_keys covers the provider +// actions' exact tag-key comparison: only a byte-identical repeat is a +// duplicate, so a differently-cased repeat creates two distinct tags. func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error { testName := "IAMCreateOpenIDConnectProvider_duplicate_tag_keys" return iamActionHandler(s, testName, func(client *iam.Client) error { @@ -141,10 +144,27 @@ func IAMCreateOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error { ThumbprintList: []string{validOIDCThumbprint}, Tags: []iamtypes.Tag{ {Key: aws.String("key"), Value: aws.String("one")}, - {Key: aws.String("KEY"), Value: aws.String("two")}, + {Key: aws.String("key"), Value: aws.String("two")}, }, }) - return checkIAMApiErr(err, iamerr.InvalidInput("Duplicate tag keys found. Please note that Tag keys are case insensitive.")) + if err := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateExactTagKeys)); err != nil { + return err + } + + arn, err := createOIDCProviderReturningArnWithTags(client, []iamtypes.Tag{ + {Key: aws.String("key"), Value: aws.String("one")}, + {Key: aws.String("KEY"), Value: aws.String("two")}, + }) + if err != nil { + return fmt.Errorf("differently-cased keys: %w", err) + } + + checkErr := checkIAMOIDCProviderTags(client, arn, map[string]string{"key": "one", "KEY": "two"}) + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr }) } @@ -469,6 +489,20 @@ func oidcProviderArn(providerURL string) string { return "arn:aws:iam::000000000000:oidc-provider/" + strings.TrimPrefix(providerURL, "https://") } +// createOIDCProviderReturningArnWithTags creates a provider at a fresh +// random URL carrying tags and returns its ARN. +func createOIDCProviderReturningArnWithTags(client *iam.Client, tags []iamtypes.Tag) (string, error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(newIAMOIDCProviderURL()), + ThumbprintList: []string{validOIDCThumbprint}, + Tags: tags, + }) + if err != nil { + return "", err + } + return aws.ToString(out.OpenIDConnectProviderArn), nil +} + func createOIDCProviderReturningArn(client *iam.Client, thumbprints []string) (string, error) { out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ Url: aws.String(newIAMOIDCProviderURL()), diff --git a/tests/integration/iam_delete_role.go b/tests/integration/iam_delete_role.go index a0e5c57a..88fc16f2 100644 --- a/tests/integration/iam_delete_role.go +++ b/tests/integration/iam_delete_role.go @@ -53,8 +53,8 @@ func IAMDeleteRole_invalid_role_name(s *S3Conf) error { func IAMDeleteRole_long_role_name(s *S3Conf) error { testName := "IAMDeleteRole_long_role_name" return iamActionHandler(s, testName, func(client *iam.Client) error { - err := deleteIAMRole(client, strings.Repeat("a", 129)) - return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + err := deleteIAMRole(client, strings.Repeat("a", 65)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) }) } diff --git a/tests/integration/iam_get_role.go b/tests/integration/iam_get_role.go index 4e20525f..9d7aa07c 100644 --- a/tests/integration/iam_get_role.go +++ b/tests/integration/iam_get_role.go @@ -57,8 +57,8 @@ func IAMGetRole_invalid_role_name(s *S3Conf) error { func IAMGetRole_long_role_name(s *S3Conf) error { testName := "IAMGetRole_long_role_name" return iamActionHandler(s, testName, func(client *iam.Client) error { - _, err := getIAMRole(client, strings.Repeat("a", 129)) - return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + _, err := getIAMRole(client, strings.Repeat("a", 65)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) }) } diff --git a/tests/integration/iam_list_access_keys.go b/tests/integration/iam_list_access_keys.go index 87efa59b..505158a0 100644 --- a/tests/integration/iam_list_access_keys.go +++ b/tests/integration/iam_list_access_keys.go @@ -63,12 +63,15 @@ func IAMListAccessKeys_invalid_max_items(s *S3Conf) error { testName := "IAMListAccessKeys_invalid_max_items" return iamActionHandler(s, testName, func(client *iam.Client) error { userName := "non-existing-" + genRandString(16) - for _, maxItems := range []int32{-1, 0, 1001} { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { _, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{ UserName: &userName, MaxItems: aws.Int32(maxItems), }) - expected := iamerr.InvalidMaxItems(fmt.Sprint(maxItems)) if checkErr := checkIAMApiErr(err, expected); checkErr != nil { return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) } @@ -94,8 +97,7 @@ func IAMListAccessKeys_invalid_max_items_format(s *S3Conf) error { date: time.Now().UTC(), headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, }, func(req *http.Request) error { - expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000") - return checkIAMAuthRequest(s, req, expected) + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) }) } diff --git a/tests/integration/iam_list_oidc_provider_tags.go b/tests/integration/iam_list_oidc_provider_tags.go new file mode 100644 index 00000000..9577b1c8 --- /dev/null +++ b/tests/integration/iam_list_oidc_provider_tags.go @@ -0,0 +1,270 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListOpenIDConnectProviderTags_missing_arn(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_missing_arn" + body := []byte(url.Values{ + "Action": {"ListOpenIDConnectProviderTags"}, + "Version": {"2010-05-08"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMListOpenIDConnectProviderTags_invalid_arn(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_invalid_arn" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tests := []struct { + name string + arn string + want iamerr.Error + }{ + {"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)}, + {"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)}, + {"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")}, + {"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")}, + } + for _, tt := range tests { + _, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{ + OpenIDConnectProviderArn: aws.String(tt.arn), + }) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMListOpenIDConnectProviderTags_invalid_max_items(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://validprovider.example.com") + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { + _, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{ + OpenIDConnectProviderArn: &arn, + MaxItems: aws.Int32(maxItems), + }) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListOpenIDConnectProviderTags_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListOpenIDConnectProviderTags"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")}, + "MaxItems": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMListOpenIDConnectProviderTags_non_existing_provider(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + _, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn}) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} + +func IAMListOpenIDConnectProviderTags_empty_result(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn}) + if err != nil { + return err + } + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %v", iamTagMap(out.Tags)) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListOpenIDConnectProviderTags_success(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createOIDCProviderReturningArnWithTags(client, []iamtypes.Tag{ + {Key: aws.String("created"), Value: aws.String("at-create-time")}, + }) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}), + }); err != nil { + return err + } + + out, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListOpenIDConnectProviderTags response request id") + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + // Tags supplied at creation and tags added afterwards are the + // same set: the tag action merges into whatever create stored. + return compareIAMTags(out.Tags, map[string]string{ + "created": "at-create-time", + "env": "prod", + "team": "storage", + }) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListOpenIDConnectProviderTags_pagination(s *S3Conf) error { + testName := "IAMListOpenIDConnectProviderTags_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + want := []string{"alpha", "beta", "gamma"} + tags := map[string]string{} + for _, key := range want { + tags[key] = key + "-value" + } + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(tags), + }); err != nil { + return err + } + + input := iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn, MaxItems: aws.Int32(1)} + var pages []*iam.ListOpenIDConnectProviderTagsOutput + for { + out, err := listIAMOIDCProviderTags(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.Tags) != 1 { + return fmt.Errorf("expected page %d to contain 1 tag, instead got %d", i+1, len(page.Tags)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, aws.ToString(page.Tags[0].Key)) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected tag keys %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMOIDCProviderTags(client *iam.Client, input *iam.ListOpenIDConnectProviderTagsInput) (*iam.ListOpenIDConnectProviderTagsOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListOpenIDConnectProviderTags(ctx, input) +} diff --git a/tests/integration/iam_list_role_policies.go b/tests/integration/iam_list_role_policies.go index bbc68da8..c58aa210 100644 --- a/tests/integration/iam_list_role_policies.go +++ b/tests/integration/iam_list_role_policies.go @@ -70,7 +70,7 @@ func IAMListRolePolicies_invalid_max_items(s *S3Conf) error { _, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1001)}) return err }(), - iamerr.InvalidMaxItems("1001"), + iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), ) deleteErr := deleteIAMRole(client, roleName) diff --git a/tests/integration/iam_list_role_tags.go b/tests/integration/iam_list_role_tags.go new file mode 100644 index 00000000..f39e3253 --- /dev/null +++ b/tests/integration/iam_list_role_tags.go @@ -0,0 +1,258 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListRoleTags_missing_role_name(s *S3Conf) error { + testName := "IAMListRoleTags_missing_role_name" + body := []byte(url.Values{ + "Action": {"ListRoleTags"}, + "Version": {"2010-05-08"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMListRoleTags_invalid_role_name(s *S3Conf) error { + testName := "IAMListRoleTags_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{ + RoleName: aws.String("invalid role name"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMListRoleTags_role_name_too_long(s *S3Conf) error { + testName := "IAMListRoleTags_role_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{ + RoleName: aws.String(strings.Repeat("a", 65)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) + }) +} + +func IAMListRoleTags_invalid_max_items(s *S3Conf) error { + testName := "IAMListRoleTags_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { + _, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{ + RoleName: aws.String("validrolename"), + MaxItems: aws.Int32(maxItems), + }) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListRoleTags_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListRoleTags_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListRoleTags"}, + "Version": {"2010-05-08"}, + "RoleName": {"validrolename"}, + "MaxItems": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMListRoleTags_non_existing_role(s *S3Conf) error { + testName := "IAMListRoleTags_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMListRoleTags_empty_result(s *S3Conf) error { + testName := "IAMListRoleTags_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName}) + if err != nil { + return err + } + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %v", iamTagMap(out.Tags)) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRoleTags_success(s *S3Conf) error { + testName := "IAMListRoleTags_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, iamTagList(map[string]string{"created": "at-create-time"})) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}), + }); err != nil { + return err + } + + out, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListRoleTags response request id") + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + // Tags supplied at creation and tags added afterwards are the + // same set: TagRole merges into whatever CreateRole stored. + return compareIAMTags(out.Tags, map[string]string{ + "created": "at-create-time", + "env": "prod", + "team": "storage", + }) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListRoleTags_pagination(s *S3Conf) error { + testName := "IAMListRoleTags_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + want := []string{"alpha", "beta", "gamma"} + tags := map[string]string{} + for _, key := range want { + tags[key] = key + "-value" + } + if _, err := tagIAMRole(client, &iam.TagRoleInput{RoleName: &roleName, Tags: iamTagList(tags)}); err != nil { + return err + } + + input := iam.ListRoleTagsInput{RoleName: &roleName, MaxItems: aws.Int32(1)} + var pages []*iam.ListRoleTagsOutput + for { + out, err := listIAMRoleTags(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.Tags) != 1 { + return fmt.Errorf("expected page %d to contain 1 tag, instead got %d", i+1, len(page.Tags)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, aws.ToString(page.Tags[0].Key)) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected tag keys %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMRoleTags(client *iam.Client, input *iam.ListRoleTagsInput) (*iam.ListRoleTagsOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListRoleTags(ctx, input) +} diff --git a/tests/integration/iam_list_roles.go b/tests/integration/iam_list_roles.go index 0849a288..16184bf2 100644 --- a/tests/integration/iam_list_roles.go +++ b/tests/integration/iam_list_roles.go @@ -58,9 +58,12 @@ func IAMListRoles_long_path_prefix(s *S3Conf) error { func IAMListRoles_invalid_max_items(s *S3Conf) error { testName := "IAMListRoles_invalid_max_items" return iamActionHandler(s, testName, func(client *iam.Client) error { - for _, maxItems := range []int32{-1, 0, 1001} { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { _, err := listIAMRoles(client, &iam.ListRolesInput{MaxItems: aws.Int32(maxItems)}) - expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems)) if checkErr := checkIAMApiErr(err, expected); checkErr != nil { return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) } @@ -85,8 +88,7 @@ func IAMListRoles_invalid_max_items_format(s *S3Conf) error { date: time.Now().UTC(), headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, }, func(req *http.Request) error { - expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000") - return checkIAMAuthRequest(s, req, expected) + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) }) } diff --git a/tests/integration/iam_list_user_policies.go b/tests/integration/iam_list_user_policies.go index c9514ef2..e53c2351 100644 --- a/tests/integration/iam_list_user_policies.go +++ b/tests/integration/iam_list_user_policies.go @@ -67,7 +67,7 @@ func IAMListUserPolicies_invalid_max_items(s *S3Conf) error { _, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)}) return err }(), - iamerr.InvalidMaxItems("1001"), + iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), ) deleteErr := deleteIAMUser(client, userName) diff --git a/tests/integration/iam_list_user_tags.go b/tests/integration/iam_list_user_tags.go new file mode 100644 index 00000000..e48d9b6d --- /dev/null +++ b/tests/integration/iam_list_user_tags.go @@ -0,0 +1,261 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMListUserTags_missing_user_name(s *S3Conf) error { + testName := "IAMListUserTags_missing_user_name" + body := []byte(url.Values{ + "Action": {"ListUserTags"}, + "Version": {"2010-05-08"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMListUserTags_invalid_user_name(s *S3Conf) error { + testName := "IAMListUserTags_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{ + UserName: aws.String("invalid user name"), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMListUserTags_user_name_too_long(s *S3Conf) error { + testName := "IAMListUserTags_user_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{ + UserName: aws.String(strings.Repeat("a", 129)), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMListUserTags_invalid_max_items(s *S3Conf) error { + testName := "IAMListUserTags_invalid_max_items" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{ + UserName: aws.String("validusername"), + MaxItems: aws.Int32(maxItems), + }) + if checkErr := checkIAMApiErr(err, expected); checkErr != nil { + return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) + } + } + return nil + }) +} + +func IAMListUserTags_invalid_max_items_format(s *S3Conf) error { + testName := "IAMListUserTags_invalid_max_items_format" + body := []byte(url.Values{ + "Action": {"ListUserTags"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "MaxItems": {"not-a-number"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) + }) +} + +func IAMListUserTags_non_existing_user(s *S3Conf) error { + testName := "IAMListUserTags_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMListUserTags_empty_result(s *S3Conf) error { + testName := "IAMListUserTags_empty_result" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + if err != nil { + return err + } + if len(out.Tags) != 0 { + return fmt.Errorf("expected no tags, instead got %v", iamTagMap(out.Tags)) + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserTags_success(s *S3Conf) error { + testName := "IAMListUserTags_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"created": "at-create-time"}), + }); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}), + }); err != nil { + return err + } + + out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected ListUserTags response request id") + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + // Tags supplied at creation and tags added afterwards are the + // same set: TagUser merges into whatever CreateUser stored. + return compareIAMTags(out.Tags, map[string]string{ + "created": "at-create-time", + "env": "prod", + "team": "storage", + }) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMListUserTags_pagination(s *S3Conf) error { + testName := "IAMListUserTags_pagination" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + want := []string{"alpha", "beta", "gamma"} + tags := map[string]string{} + for _, key := range want { + tags[key] = key + "-value" + } + if _, err := tagIAMUser(client, &iam.TagUserInput{UserName: &userName, Tags: iamTagList(tags)}); err != nil { + return err + } + + input := iam.ListUserTagsInput{UserName: &userName, MaxItems: aws.Int32(1)} + var pages []*iam.ListUserTagsOutput + for { + out, err := listIAMUserTags(client, &input) + if err != nil { + return err + } + pages = append(pages, out) + if !out.IsTruncated { + break + } + input.Marker = out.Marker + } + + if len(pages) != len(want) { + return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages)) + } + var got []string + for i, page := range pages { + if len(page.Tags) != 1 { + return fmt.Errorf("expected page %d to contain 1 tag, instead got %d", i+1, len(page.Tags)) + } + if page.IsTruncated != (i < len(pages)-1) { + return fmt.Errorf("unexpected IsTruncated value on page %d", i+1) + } + got = append(got, aws.ToString(page.Tags[0].Key)) + } + slices.Sort(got) + if !slices.Equal(got, want) { + return fmt.Errorf("expected tag keys %v, instead got %v", want, got) + } + return nil + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func listIAMUserTags(client *iam.Client, input *iam.ListUserTagsInput) (*iam.ListUserTagsOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.ListUserTags(ctx, input) +} diff --git a/tests/integration/iam_list_users.go b/tests/integration/iam_list_users.go index 7da6b266..555792c7 100644 --- a/tests/integration/iam_list_users.go +++ b/tests/integration/iam_list_users.go @@ -57,9 +57,12 @@ func IAMListUsers_long_path_prefix(s *S3Conf) error { func IAMListUsers_invalid_max_items(s *S3Conf) error { testName := "IAMListUsers_invalid_max_items" return iamActionHandler(s, testName, func(client *iam.Client) error { - for _, maxItems := range []int32{-1, 0, 1001} { + for maxItems, expected := range map[int32]iamerr.Error{ + -1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow), + 1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh), + } { _, err := listIAMUsers(client, &iam.ListUsersInput{MaxItems: aws.Int32(maxItems)}) - expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems)) if checkErr := checkIAMApiErr(err, expected); checkErr != nil { return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr) } @@ -84,8 +87,7 @@ func IAMListUsers_invalid_max_items_format(s *S3Conf) error { date: time.Now().UTC(), headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, }, func(req *http.Request) error { - expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000") - return checkIAMAuthRequest(s, req, expected) + return checkIAMAuthRequest(s, req, iamerr.MalformedInput()) }) } diff --git a/tests/integration/iam_tag_oidc_provider.go b/tests/integration/iam_tag_oidc_provider.go new file mode 100644 index 00000000..6022de06 --- /dev/null +++ b/tests/integration/iam_tag_oidc_provider.go @@ -0,0 +1,454 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMTagOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"TagOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"prod"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMTagOpenIDConnectProvider_invalid_arn(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_invalid_arn" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tests := []struct { + name string + arn string + want iamerr.Error + }{ + {"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)}, + {"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)}, + {"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")}, + {"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")}, + } + for _, tt := range tests { + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: aws.String(tt.arn), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMTagOpenIDConnectProvider_missing_tags(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_missing_tags" + body := []byte(url.Values{ + "Action": {"TagOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("tags")) + }) +} + +func IAMTagOpenIDConnectProvider_missing_tag_key(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_missing_tag_key" + body := []byte(url.Values{ + "Action": {"TagOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")}, + "Tags.member.1.Value": {"prod"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingTagKey(1)) + }) +} + +func IAMTagOpenIDConnectProvider_missing_tag_value(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_missing_tag_value" + body := []byte(url.Values{ + "Action": {"TagOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")}, + "Tags.member.1.Key": {"env"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingTagValue(1)) + }) +} + +func IAMTagOpenIDConnectProvider_invalid_tags(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_invalid_tags" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://validprovider.example.com") + tests := []struct { + name string + tags []iamtypes.Tag + want iamerr.Error + }{ + {"empty_key", []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}}, iamerr.TagKeyTooShort(1)}, + {"key_too_long", iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}), iamerr.TagKeyTooLong(1)}, + {"invalid_key", iamTagList(map[string]string{"invalid*key": "prod"}), iamerr.InvalidTagKey(1)}, + {"value_too_long", iamTagList(map[string]string{"env": strings.Repeat("v", 257)}), iamerr.TagValueTooLong(1)}, + {"invalid_value", iamTagList(map[string]string{"env": "invalid*value"}), iamerr.InvalidTagValue(1)}, + } + for _, tt := range tests { + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: tt.tags, + }) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +// IAMTagOpenIDConnectProvider_duplicate_tag_keys covers the provider +// actions' exact tag-key comparison: an identical key twice in one request +// is rejected, where a differently-cased repeat is two separate tags. +func IAMTagOpenIDConnectProvider_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String("env"), Value: aws.String("staging")}, + }, + }) + if err := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateExactTagKeys)); err != nil { + return err + } + + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String("ENV"), Value: aws.String("staging")}, + }, + }); err != nil { + return fmt.Errorf("differently-cased keys: %w", err) + } + return checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "prod", "ENV": "staging"}) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMTagOpenIDConnectProvider_too_many_tags(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_too_many_tags" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: aws.String(oidcProviderArn("https://validprovider.example.com")), + Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags)) + }) +} + +func IAMTagOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} + +// IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup covers a +// malformed tag naming a provider that does not exist: the request is +// validated in full before the provider is looked up, so the tag error +// wins over NoSuchEntity. +func IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_invalid_tags_precede_lookup" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: aws.String(oidcProviderArn("https://" + genRandString(16) + ".example.com")), + Tags: iamTagList(map[string]string{"invalid*key": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagKey(1)) + }) +} + +func IAMTagOpenIDConnectProvider_tag_limit_exceeded(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_tag_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: numberedIAMTags(1, storage.MaxTagsPerResource), + }); err != nil { + return err + } + + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"key1": "replaced"}), + }); err != nil { + return fmt.Errorf("replacing a tag at the quota: %w", err) + } + + // A differently-cased key is a new tag, so it overflows. + _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"KEY1": "x"}), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded)) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMTagOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + // An empty tag value is legal; only the key has a minimum length. + want := map[string]string{"env": "prod", "team": "storage", "empty": ""} + out, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(want), + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected TagOpenIDConnectProvider response request id") + } + + if err := checkIAMOIDCProviderTags(client, arn, want); err != nil { + return err + } + // GetOpenIDConnectProvider reports the same tags the tag actions maintain. + provider, err := getIAMOIDCProvider(client, arn) + if err != nil { + return err + } + return compareIAMTags(provider.Tags, want) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMTagOpenIDConnectProvider_overwrites_existing_tag covers re-tagging a +// key that is already present: the value is replaced rather than added +// alongside, while a differently-cased key is a separate tag that leaves +// the original in place. +func IAMTagOpenIDConnectProvider_overwrites_existing_tag(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_overwrites_existing_tag" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "staging"}), + }); err != nil { + return err + } + if err := checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "staging"}); err != nil { + return err + } + + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"ENV": "qa"}), + }); err != nil { + return err + } + return checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "staging", "ENV": "qa"}) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMTagOpenIDConnectProvider_isolated_per_provider covers two providers +// carrying the same tag keys: each set is its own, in both directions. +func IAMTagOpenIDConnectProvider_isolated_per_provider(s *S3Conf) error { + testName := "IAMTagOpenIDConnectProvider_isolated_per_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + first, err := createTestOIDCProvider(client) + if err != nil { + return err + } + second, err := createTestOIDCProvider(client) + if err != nil { + deleteOIDCProvider(client, first) + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &first, + Tags: iamTagList(map[string]string{"owner": "first"}), + }); err != nil { + return err + } + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &second, + Tags: iamTagList(map[string]string{"owner": "second"}), + }); err != nil { + return err + } + + if err := checkIAMOIDCProviderTags(client, first, map[string]string{"owner": "first"}); err != nil { + return err + } + if err := checkIAMOIDCProviderTags(client, second, map[string]string{"owner": "second"}); err != nil { + return err + } + + // Untagging one must not strip the other's identically-named key. + if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &first, + TagKeys: []string{"owner"}, + }); err != nil { + return err + } + if err := checkIAMOIDCProviderTags(client, first, map[string]string{}); err != nil { + return err + } + return checkIAMOIDCProviderTags(client, second, map[string]string{"owner": "second"}) + }() + + deleteErr := deleteOIDCProvider(client, first) + if secondErr := deleteOIDCProvider(client, second); deleteErr == nil { + deleteErr = secondErr + } + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func tagOIDCProvider(client *iam.Client, input *iam.TagOpenIDConnectProviderInput) (*iam.TagOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.TagOpenIDConnectProvider(ctx, input) +} + +// checkIAMOIDCProviderTags asserts ListOpenIDConnectProviderTags reports +// exactly want for arn. +func checkIAMOIDCProviderTags(client *iam.Client, arn string, want map[string]string) error { + out, err := listIAMOIDCProviderTags(client, &iam.ListOpenIDConnectProviderTagsInput{OpenIDConnectProviderArn: &arn}) + if err != nil { + return err + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return compareIAMTags(out.Tags, want) +} diff --git a/tests/integration/iam_tag_role.go b/tests/integration/iam_tag_role.go new file mode 100644 index 00000000..b1dd2ef6 --- /dev/null +++ b/tests/integration/iam_tag_role.go @@ -0,0 +1,453 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMTagRole_missing_role_name(s *S3Conf) error { + testName := "IAMTagRole_missing_role_name" + body := []byte(url.Values{ + "Action": {"TagRole"}, + "Version": {"2010-05-08"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"prod"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMTagRole_invalid_role_name(s *S3Conf) error { + testName := "IAMTagRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("invalid role name"), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMTagRole_role_name_too_long(s *S3Conf) error { + testName := "IAMTagRole_role_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String(strings.Repeat("a", 65)), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) + }) +} + +func IAMTagRole_missing_tags(s *S3Conf) error { + testName := "IAMTagRole_missing_tags" + body := []byte(url.Values{ + "Action": {"TagRole"}, + "Version": {"2010-05-08"}, + "RoleName": {"validrolename"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("tags")) + }) +} + +func IAMTagRole_missing_tag_key(s *S3Conf) error { + testName := "IAMTagRole_missing_tag_key" + body := []byte(url.Values{ + "Action": {"TagRole"}, + "Version": {"2010-05-08"}, + "RoleName": {"validrolename"}, + "Tags.member.1.Value": {"prod"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingTagKey(1)) + }) +} + +func IAMTagRole_missing_tag_value(s *S3Conf) error { + testName := "IAMTagRole_missing_tag_value" + body := []byte(url.Values{ + "Action": {"TagRole"}, + "Version": {"2010-05-08"}, + "RoleName": {"validrolename"}, + "Tags.member.1.Key": {"env"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingTagValue(1)) + }) +} + +func IAMTagRole_empty_tag_key(s *S3Conf) error { + testName := "IAMTagRole_empty_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}}, + }) + return checkIAMApiErr(err, iamerr.TagKeyTooShort(1)) + }) +} + +func IAMTagRole_tag_key_too_long(s *S3Conf) error { + testName := "IAMTagRole_tag_key_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}), + }) + return checkIAMApiErr(err, iamerr.TagKeyTooLong(1)) + }) +} + +func IAMTagRole_invalid_tag_key(s *S3Conf) error { + testName := "IAMTagRole_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: iamTagList(map[string]string{"invalid*key": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagKey(1)) + }) +} + +func IAMTagRole_tag_value_too_long(s *S3Conf) error { + testName := "IAMTagRole_tag_value_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: iamTagList(map[string]string{"env": strings.Repeat("v", 257)}), + }) + return checkIAMApiErr(err, iamerr.TagValueTooLong(1)) + }) +} + +func IAMTagRole_invalid_tag_value(s *S3Conf) error { + testName := "IAMTagRole_invalid_tag_value" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: iamTagList(map[string]string{"env": "invalid*value"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagValue(1)) + }) +} + +// IAMTagRole_duplicate_tag_keys covers both an exact repeat and a +// differently-cased repeat: IAM compares tag keys case-insensitively, so +// both are the same key twice in one request. +func IAMTagRole_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMTagRole_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, second := range []string{"env", "ENV"} { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String(second), Value: aws.String("staging")}, + }, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)); checkErr != nil { + return fmt.Errorf("duplicate key %q: %w", second, checkErr) + } + } + return nil + }) +} + +func IAMTagRole_too_many_tags(s *S3Conf) error { + testName := "IAMTagRole_too_many_tags" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: aws.String("validrolename"), + Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags)) + }) +} + +func IAMTagRole_non_existing_role(s *S3Conf) error { + testName := "IAMTagRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMTagRole_tag_limit_exceeded(s *S3Conf) error { + testName := "IAMTagRole_tag_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: numberedIAMTags(1, storage.MaxTagsPerResource), + }); err != nil { + return err + } + + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"key1": "replaced"}), + }); err != nil { + return fmt.Errorf("replacing a tag at the quota: %w", err) + } + + _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"overflow": "x"}), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded)) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMTagRole_success(s *S3Conf) error { + testName := "IAMTagRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + // An empty tag value is legal; only the key has a minimum length. + want := map[string]string{"env": "prod", "team": "storage", "empty": ""} + out, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(want), + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected TagRole response request id") + } + + if err := checkIAMRoleTags(client, roleName, want); err != nil { + return err + } + // GetRole reports the same tags the tag actions maintain. + role, err := getIAMRole(client, roleName) + if err != nil { + return err + } + return compareIAMTags(role.Role.Tags, want) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMTagRole_overwrites_existing_tag covers re-tagging a key that is +// already present: the value is replaced rather than added alongside, and a +// differently-cased key is the same tag — the newly supplied casing wins. +func IAMTagRole_overwrites_existing_tag(s *S3Conf) error { + testName := "IAMTagRole_overwrites_existing_tag" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "staging"}), + }); err != nil { + return err + } + if err := checkIAMRoleTags(client, roleName, map[string]string{"env": "staging"}); err != nil { + return err + } + + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"ENV": "qa"}), + }); err != nil { + return err + } + return checkIAMRoleTags(client, roleName, map[string]string{"ENV": "qa"}) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMTagRole_isolated_from_same_named_user covers a role and a user sharing +// a name: they are separate entities, so tagging one leaves the other's +// tags untouched in both directions. +func IAMTagRole_isolated_from_same_named_user(s *S3Conf) error { + testName := "IAMTagRole_isolated_from_same_named_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + name := "shared-name-" + genRandString(16) + if _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &name, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + }); err != nil { + return err + } + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &name}); err != nil { + deleteIAMRole(client, name) + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &name, + Tags: iamTagList(map[string]string{"owner": "role"}), + }); err != nil { + return err + } + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &name, + Tags: iamTagList(map[string]string{"owner": "user"}), + }); err != nil { + return err + } + + if err := checkIAMRoleTags(client, name, map[string]string{"owner": "role"}); err != nil { + return err + } + if err := checkIAMUserTags(client, name, map[string]string{"owner": "user"}); err != nil { + return err + } + + // Untagging the user must not strip the role's identically-named key. + if _, err := untagIAMUser(client, &iam.UntagUserInput{UserName: &name, TagKeys: []string{"owner"}}); err != nil { + return err + } + if err := checkIAMUserTags(client, name, map[string]string{}); err != nil { + return err + } + return checkIAMRoleTags(client, name, map[string]string{"owner": "role"}) + }() + + deleteErr := deleteIAMUser(client, name) + if roleErr := deleteIAMRole(client, name); deleteErr == nil { + deleteErr = roleErr + } + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func tagIAMRole(client *iam.Client, input *iam.TagRoleInput) (*iam.TagRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.TagRole(ctx, input) +} + +// createTaggableIAMRole creates a role carrying tags, if any, and returns +// its generated name. +func createTaggableIAMRole(client *iam.Client, tags []iamtypes.Tag) (string, error) { + roleName := newIAMRoleName() + _, err := createIAMRole(client, &iam.CreateRoleInput{ + RoleName: &roleName, + AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument), + Tags: tags, + }) + if err != nil { + return "", err + } + return roleName, nil +} + +// checkIAMRoleTags asserts ListRoleTags reports exactly want for roleName. +func checkIAMRoleTags(client *iam.Client, roleName string, want map[string]string) error { + out, err := listIAMRoleTags(client, &iam.ListRoleTagsInput{RoleName: &roleName}) + if err != nil { + return err + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return compareIAMTags(out.Tags, want) +} diff --git a/tests/integration/iam_tag_user.go b/tests/integration/iam_tag_user.go new file mode 100644 index 00000000..62bd89cb --- /dev/null +++ b/tests/integration/iam_tag_user.go @@ -0,0 +1,427 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/versity/versitygw/iamapi/iamerr" + "github.com/versity/versitygw/iamapi/storage" +) + +func IAMTagUser_missing_user_name(s *S3Conf) error { + testName := "IAMTagUser_missing_user_name" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "Tags.member.1.Key": {"env"}, + "Tags.member.1.Value": {"prod"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMTagUser_invalid_user_name(s *S3Conf) error { + testName := "IAMTagUser_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("invalid user name"), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMTagUser_user_name_too_long(s *S3Conf) error { + testName := "IAMTagUser_user_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String(strings.Repeat("a", 129)), + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMTagUser_missing_tags(s *S3Conf) error { + testName := "IAMTagUser_missing_tags" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("tags")) + }) +} + +func IAMTagUser_missing_tag_key(s *S3Conf) error { + testName := "IAMTagUser_missing_tag_key" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "Tags.member.1.Value": {"prod"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingTagKey(1)) + }) +} + +func IAMTagUser_missing_tag_value(s *S3Conf) error { + testName := "IAMTagUser_missing_tag_value" + body := []byte(url.Values{ + "Action": {"TagUser"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + "Tags.member.1.Key": {"env"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingTagValue(1)) + }) +} + +func IAMTagUser_empty_tag_key(s *S3Conf) error { + testName := "IAMTagUser_empty_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}}, + }) + return checkIAMApiErr(err, iamerr.TagKeyTooShort(1)) + }) +} + +func IAMTagUser_tag_key_too_long(s *S3Conf) error { + testName := "IAMTagUser_tag_key_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}), + }) + return checkIAMApiErr(err, iamerr.TagKeyTooLong(1)) + }) +} + +func IAMTagUser_invalid_tag_key(s *S3Conf) error { + testName := "IAMTagUser_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{"invalid*key": "prod"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagKey(1)) + }) +} + +func IAMTagUser_tag_value_too_long(s *S3Conf) error { + testName := "IAMTagUser_tag_value_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{"env": strings.Repeat("v", 257)}), + }) + return checkIAMApiErr(err, iamerr.TagValueTooLong(1)) + }) +} + +func IAMTagUser_invalid_tag_value(s *S3Conf) error { + testName := "IAMTagUser_invalid_tag_value" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: iamTagList(map[string]string{"env": "invalid*value"}), + }) + return checkIAMApiErr(err, iamerr.InvalidTagValue(1)) + }) +} + +// IAMTagUser_duplicate_tag_keys covers both an exact repeat and a +// differently-cased repeat: IAM compares tag keys case-insensitively, so +// both are the same key twice in one request. +func IAMTagUser_duplicate_tag_keys(s *S3Conf) error { + testName := "IAMTagUser_duplicate_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, second := range []string{"env", "ENV"} { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: []iamtypes.Tag{ + {Key: aws.String("env"), Value: aws.String("prod")}, + {Key: aws.String(second), Value: aws.String("staging")}, + }, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)); checkErr != nil { + return fmt.Errorf("duplicate key %q: %w", second, checkErr) + } + } + return nil + }) +} + +func IAMTagUser_too_many_tags(s *S3Conf) error { + testName := "IAMTagUser_too_many_tags" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: aws.String("validusername"), + Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags)) + }) +} + +func IAMTagUser_non_existing_user(s *S3Conf) error { + testName := "IAMTagUser_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMTagUser_tag_limit_exceeded(s *S3Conf) error { + testName := "IAMTagUser_tag_limit_exceeded" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: numberedIAMTags(1, storage.MaxTagsPerResource), + }); err != nil { + return err + } + + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"key1": "replaced"}), + }); err != nil { + return fmt.Errorf("replacing a tag at the quota: %w", err) + } + + _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"overflow": "x"}), + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded)) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func IAMTagUser_success(s *S3Conf) error { + testName := "IAMTagUser_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + // An empty tag value is legal; only the key has a minimum length. + want := map[string]string{"env": "prod", "team": "storage", "empty": ""} + out, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(want), + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected TagUser response request id") + } + + if err := checkIAMUserTags(client, userName, want); err != nil { + return err + } + // GetUser reports the same tags the tag actions maintain. + user, err := getIAMUser(client, &iam.GetUserInput{UserName: &userName}) + if err != nil { + return err + } + return compareIAMTags(user.User.Tags, want) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMTagUser_overwrites_existing_tag covers re-tagging a key that is +// already present: the value is replaced rather than added alongside, and a +// differently-cased key is the same tag — the newly supplied casing wins. +func IAMTagUser_overwrites_existing_tag(s *S3Conf) error { + testName := "IAMTagUser_overwrites_existing_tag" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "staging"}), + }); err != nil { + return err + } + if err := checkIAMUserTags(client, userName, map[string]string{"env": "staging"}); err != nil { + return err + } + + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"ENV": "qa"}), + }); err != nil { + return err + } + return checkIAMUserTags(client, userName, map[string]string{"ENV": "qa"}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// maxIAMTagMembersPerRequest mirrors the per-request Tags/TagKeys member +// cap the service enforces +const maxIAMTagMembersPerRequest = 50 + +func tagIAMUser(client *iam.Client, input *iam.TagUserInput) (*iam.TagUserOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.TagUser(ctx, input) +} + +func iamTagList(tags map[string]string) []iamtypes.Tag { + list := make([]iamtypes.Tag, 0, len(tags)) + for key, value := range tags { + list = append(list, iamtypes.Tag{Key: aws.String(key), Value: aws.String(value)}) + } + return list +} + +// numberedIAMTags builds count tags named key/value starting at +// first, for exercising the per-request and per-user tag quotas. +func numberedIAMTags(first, count int) []iamtypes.Tag { + list := make([]iamtypes.Tag, 0, count) + for i := first; i < first+count; i++ { + list = append(list, iamtypes.Tag{ + Key: aws.String(fmt.Sprintf("key%d", i)), + Value: aws.String(fmt.Sprintf("value%d", i)), + }) + } + return list +} + +// checkIAMUserTags asserts ListUserTags reports exactly want for userName. +func checkIAMUserTags(client *iam.Client, userName string, want map[string]string) error { + out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName}) + if err != nil { + return err + } + if out.IsTruncated { + return fmt.Errorf("expected IsTruncated to be false") + } + return compareIAMTags(out.Tags, want) +} + +func compareIAMTags(got []iamtypes.Tag, want map[string]string) error { + if len(got) != len(want) { + return fmt.Errorf("expected %d tags, instead got %d: %v", len(want), len(got), iamTagMap(got)) + } + for key, value := range want { + found, ok := iamTagMap(got)[key] + if !ok { + return fmt.Errorf("expected tag %q to be present, instead got %v", key, iamTagMap(got)) + } + if found != value { + return fmt.Errorf("expected tag %q to be %q, instead got %q", key, value, found) + } + } + return nil +} + +func iamTagMap(tags []iamtypes.Tag) map[string]string { + out := make(map[string]string, len(tags)) + for _, tag := range tags { + out[aws.ToString(tag.Key)] = aws.ToString(tag.Value) + } + return out +} diff --git a/tests/integration/iam_untag_oidc_provider.go b/tests/integration/iam_untag_oidc_provider.go new file mode 100644 index 00000000..511c7613 --- /dev/null +++ b/tests/integration/iam_untag_oidc_provider.go @@ -0,0 +1,311 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUntagOpenIDConnectProvider_missing_arn(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_missing_arn" + body := []byte(url.Values{ + "Action": {"UntagOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "TagKeys.member.1": {"env"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("openIDConnectProviderArn")) + }) +} + +func IAMUntagOpenIDConnectProvider_invalid_arn(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_invalid_arn" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tests := []struct { + name string + arn string + want iamerr.Error + }{ + {"too_short", strings.Repeat("a", 19), iamerr.ValueTooShort("openIDConnectProviderArn", 20)}, + {"too_long", strings.Repeat("a", 2049), iamerr.ValueTooLong("openIDConnectProviderArn", 2048)}, + {"wrong_resource_type", "arn:aws:iam::000000000000:role/some-role", iamerr.ValidationError("Invalid resource type in ARN")}, + {"foreign_account_id", "arn:aws:iam::123456789012:oidc-provider/example.com", iamerr.AccessDeniedOIDCProvider("000000000000", "arn:aws:iam::123456789012:oidc-provider/example.com")}, + } + for _, tt := range tests { + _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: aws.String(tt.arn), + TagKeys: []string{"env"}, + }) + if checkErr := checkIAMApiErr(err, tt.want); checkErr != nil { + return fmt.Errorf("%s: %w", tt.name, checkErr) + } + } + return nil + }) +} + +func IAMUntagOpenIDConnectProvider_missing_tag_keys(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_missing_tag_keys" + body := []byte(url.Values{ + "Action": {"UntagOpenIDConnectProvider"}, + "Version": {"2010-05-08"}, + "OpenIDConnectProviderArn": {oidcProviderArn("https://validprovider.example.com")}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("tagKeys")) + }) +} + +func IAMUntagOpenIDConnectProvider_invalid_tag_key(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://validprovider.example.com") + for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} { + _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{tagKey}, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys)); checkErr != nil { + return fmt.Errorf("tag key %q: %w", tagKey, checkErr) + } + } + return nil + }) +} + +func IAMUntagOpenIDConnectProvider_too_many_tag_keys(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_too_many_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tagKeys := make([]string, 0, maxIAMTagMembersPerRequest+1) + for i := range maxIAMTagMembersPerRequest + 1 { + tagKeys = append(tagKeys, fmt.Sprintf("key%d", i+1)) + } + + _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: aws.String(oidcProviderArn("https://validprovider.example.com")), + TagKeys: tagKeys, + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys)) + }) +} + +func IAMUntagOpenIDConnectProvider_non_existing_provider(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_non_existing_provider" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn := oidcProviderArn("https://" + genRandString(16) + ".example.com") + _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityOIDCProviderDelete(arn)) + }) +} + +func IAMUntagOpenIDConnectProvider_success(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}), + }); err != nil { + return err + } + + out, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"env", "owner"}, + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UntagOpenIDConnectProvider response request id") + } + + return checkIAMOIDCProviderTags(client, arn, map[string]string{"team": "storage"}) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagOpenIDConnectProvider_removal_is_idempotent covers the two ways a +// request can name a key that removes nothing: a key the provider never +// carried, and the same key twice in one request. Neither is an error — +// unlike TagOpenIDConnectProvider, which rejects a repeated key outright. +func IAMUntagOpenIDConnectProvider_removal_is_idempotent(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_removal_is_idempotent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + + if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"never-existed"}, + }); err != nil { + return fmt.Errorf("removing a key the provider does not carry: %w", err) + } + if err := checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "prod"}); err != nil { + return err + } + + if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"env", "env"}, + }); err != nil { + return fmt.Errorf("removing the same key twice: %w", err) + } + return checkIAMOIDCProviderTags(client, arn, map[string]string{}) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagOpenIDConnectProvider_case_sensitive_key covers removal by a +// differently-cased key: provider tag keys are compared exactly, so the +// stored tag survives and only its exact key removes it. +func IAMUntagOpenIDConnectProvider_case_sensitive_key(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_case_sensitive_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"EnV"}, + }); err != nil { + return err + } + if err := checkIAMOIDCProviderTags(client, arn, map[string]string{"env": "prod"}); err != nil { + return err + } + + if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"env"}, + }); err != nil { + return err + } + return checkIAMOIDCProviderTags(client, arn, map[string]string{}) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagOpenIDConnectProvider_removes_only_named_keys covers a partial +// removal leaving the rest of the set intact, including a key whose name +// only prefixes one of the supplied keys — matching is exact, not by prefix. +func IAMUntagOpenIDConnectProvider_removes_only_named_keys(s *S3Conf) error { + testName := "IAMUntagOpenIDConnectProvider_removes_only_named_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + arn, err := createTestOIDCProvider(client) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagOIDCProvider(client, &iam.TagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + Tags: iamTagList(map[string]string{"env": "prod", "environment": "prod", "team": "storage"}), + }); err != nil { + return err + } + if _, err := untagOIDCProvider(client, &iam.UntagOpenIDConnectProviderInput{ + OpenIDConnectProviderArn: &arn, + TagKeys: []string{"env"}, + }); err != nil { + return err + } + return checkIAMOIDCProviderTags(client, arn, map[string]string{"environment": "prod", "team": "storage"}) + }() + + deleteErr := deleteOIDCProvider(client, arn) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func untagOIDCProvider(client *iam.Client, input *iam.UntagOpenIDConnectProviderInput) (*iam.UntagOpenIDConnectProviderOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UntagOpenIDConnectProvider(ctx, input) +} diff --git a/tests/integration/iam_untag_role.go b/tests/integration/iam_untag_role.go new file mode 100644 index 00000000..ec361503 --- /dev/null +++ b/tests/integration/iam_untag_role.go @@ -0,0 +1,296 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUntagRole_missing_role_name(s *S3Conf) error { + testName := "IAMUntagRole_missing_role_name" + body := []byte(url.Values{ + "Action": {"UntagRole"}, + "Version": {"2010-05-08"}, + "TagKeys.member.1": {"env"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("roleName")) + }) +} + +func IAMUntagRole_invalid_role_name(s *S3Conf) error { + testName := "IAMUntagRole_invalid_role_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: aws.String("invalid role name"), + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("roleName")) + }) +} + +func IAMUntagRole_role_name_too_long(s *S3Conf) error { + testName := "IAMUntagRole_role_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: aws.String(strings.Repeat("a", 65)), + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) + }) +} + +func IAMUntagRole_missing_tag_keys(s *S3Conf) error { + testName := "IAMUntagRole_missing_tag_keys" + body := []byte(url.Values{ + "Action": {"UntagRole"}, + "Version": {"2010-05-08"}, + "RoleName": {"validrolename"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("tagKeys")) + }) +} + +func IAMUntagRole_invalid_tag_key(s *S3Conf) error { + testName := "IAMUntagRole_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} { + _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: aws.String("validrolename"), + TagKeys: []string{tagKey}, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys)); checkErr != nil { + return fmt.Errorf("tag key %q: %w", tagKey, checkErr) + } + } + return nil + }) +} + +func IAMUntagRole_too_many_tag_keys(s *S3Conf) error { + testName := "IAMUntagRole_too_many_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tagKeys := make([]string, 0, maxIAMTagMembersPerRequest+1) + for i := range maxIAMTagMembersPerRequest + 1 { + tagKeys = append(tagKeys, fmt.Sprintf("key%d", i+1)) + } + + _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: aws.String("validrolename"), + TagKeys: tagKeys, + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys)) + }) +} + +func IAMUntagRole_non_existing_role(s *S3Conf) error { + testName := "IAMUntagRole_non_existing_role" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName := "non-existing-" + genRandString(16) + _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: &roleName, + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityRole(roleName)) + }) +} + +func IAMUntagRole_success(s *S3Conf) error { + testName := "IAMUntagRole_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}), + }); err != nil { + return err + } + + out, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: &roleName, + TagKeys: []string{"env", "owner"}, + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UntagRole response request id") + } + + return checkIAMRoleTags(client, roleName, map[string]string{"team": "storage"}) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagRole_removal_is_idempotent covers the two ways a request can name +// a key that removes nothing: a key the role never carried, and the same +// key twice in one request. Neither is an error — unlike TagRole, which +// rejects a repeated key outright. +func IAMUntagRole_removal_is_idempotent(s *S3Conf) error { + testName := "IAMUntagRole_removal_is_idempotent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + + if _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: &roleName, + TagKeys: []string{"never-existed"}, + }); err != nil { + return fmt.Errorf("removing a key the role does not carry: %w", err) + } + if err := checkIAMRoleTags(client, roleName, map[string]string{"env": "prod"}); err != nil { + return err + } + + if _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: &roleName, + TagKeys: []string{"env", "env"}, + }); err != nil { + return fmt.Errorf("removing the same key twice: %w", err) + } + return checkIAMRoleTags(client, roleName, map[string]string{}) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagRole_case_insensitive_key covers removal by a differently-cased +// key: IAM compares tag keys case-insensitively, so the tag is removed even +// though the supplied key does not match the stored casing. +func IAMUntagRole_case_insensitive_key(s *S3Conf) error { + testName := "IAMUntagRole_case_insensitive_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: &roleName, + TagKeys: []string{"EnV"}, + }); err != nil { + return err + } + return checkIAMRoleTags(client, roleName, map[string]string{}) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagRole_removes_only_named_keys covers a partial removal leaving the +// rest of the set intact, including a key whose name only prefixes one of +// the supplied keys — matching is exact, not by prefix. +func IAMUntagRole_removes_only_named_keys(s *S3Conf) error { + testName := "IAMUntagRole_removes_only_named_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + roleName, err := createTaggableIAMRole(client, nil) + if err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMRole(client, &iam.TagRoleInput{ + RoleName: &roleName, + Tags: iamTagList(map[string]string{"env": "prod", "environment": "prod", "team": "storage"}), + }); err != nil { + return err + } + if _, err := untagIAMRole(client, &iam.UntagRoleInput{ + RoleName: &roleName, + TagKeys: []string{"env"}, + }); err != nil { + return err + } + return checkIAMRoleTags(client, roleName, map[string]string{"environment": "prod", "team": "storage"}) + }() + + deleteErr := deleteIAMRole(client, roleName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func untagIAMRole(client *iam.Client, input *iam.UntagRoleInput) (*iam.UntagRoleOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UntagRole(ctx, input) +} diff --git a/tests/integration/iam_untag_user.go b/tests/integration/iam_untag_user.go new file mode 100644 index 00000000..e5dd216b --- /dev/null +++ b/tests/integration/iam_untag_user.go @@ -0,0 +1,265 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/versity/versitygw/iamapi/iamerr" +) + +func IAMUntagUser_missing_user_name(s *S3Conf) error { + testName := "IAMUntagUser_missing_user_name" + body := []byte(url.Values{ + "Action": {"UntagUser"}, + "Version": {"2010-05-08"}, + "TagKeys.member.1": {"env"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName")) + }) +} + +func IAMUntagUser_invalid_user_name(s *S3Conf) error { + testName := "IAMUntagUser_invalid_user_name" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String("invalid user name"), + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.InvalidUserName("userName")) + }) +} + +func IAMUntagUser_user_name_too_long(s *S3Conf) error { + testName := "IAMUntagUser_user_name_too_long" + return iamActionHandler(s, testName, func(client *iam.Client) error { + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String(strings.Repeat("a", 129)), + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128)) + }) +} + +func IAMUntagUser_missing_tag_keys(s *S3Conf) error { + testName := "IAMUntagUser_missing_tag_keys" + body := []byte(url.Values{ + "Action": {"UntagUser"}, + "Version": {"2010-05-08"}, + "UserName": {"validusername"}, + }.Encode()) + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPost, + service: "iam", + region: iamAuthRegion, + body: body, + date: time.Now().UTC(), + headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }, func(req *http.Request) error { + return checkIAMAuthRequest(s, req, iamerr.MissingValue("tagKeys")) + }) +} + +// IAMUntagUser_invalid_tag_key covers every malformed TagKeys member the +// same way IAM does: whichever constraint fails — empty, over-long, or +// outside the allowed charset — the response is one generic error naming +// the whole constraint set, not the individual constraint that tripped. +func IAMUntagUser_invalid_tag_key(s *S3Conf) error { + testName := "IAMUntagUser_invalid_tag_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} { + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String("validusername"), + TagKeys: []string{tagKey}, + }) + if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys)); checkErr != nil { + return fmt.Errorf("tag key %q: %w", tagKey, checkErr) + } + } + return nil + }) +} + +func IAMUntagUser_too_many_tag_keys(s *S3Conf) error { + testName := "IAMUntagUser_too_many_tag_keys" + return iamActionHandler(s, testName, func(client *iam.Client) error { + tagKeys := make([]string, 0, maxIAMTagMembersPerRequest+1) + for i := range maxIAMTagMembersPerRequest + 1 { + tagKeys = append(tagKeys, fmt.Sprintf("key%d", i+1)) + } + + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: aws.String("validusername"), + TagKeys: tagKeys, + }) + return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys)) + }) +} + +func IAMUntagUser_non_existing_user(s *S3Conf) error { + testName := "IAMUntagUser_non_existing_user" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := "non-existing-" + genRandString(16) + _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"env"}, + }) + return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName)) + }) +} + +func IAMUntagUser_success(s *S3Conf) error { + testName := "IAMUntagUser_success" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}), + }); err != nil { + return err + } + + out, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"env", "owner"}, + }) + if err != nil { + return err + } + if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" { + return fmt.Errorf("expected UntagUser response request id") + } + + return checkIAMUserTags(client, userName, map[string]string{"team": "storage"}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagUser_removal_is_idempotent covers the two ways a request can name +// a key that removes nothing: a key the user never carried, and the same +// key twice in one request. Neither is an error — unlike TagUser, which +// rejects a repeated key outright. +func IAMUntagUser_removal_is_idempotent(s *S3Conf) error { + testName := "IAMUntagUser_removal_is_idempotent" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + + if _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"never-existed"}, + }); err != nil { + return fmt.Errorf("removing a key the user does not carry: %w", err) + } + if err := checkIAMUserTags(client, userName, map[string]string{"env": "prod"}); err != nil { + return err + } + + if _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"env", "env"}, + }); err != nil { + return fmt.Errorf("removing the same key twice: %w", err) + } + return checkIAMUserTags(client, userName, map[string]string{}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +// IAMUntagUser_case_insensitive_key covers removal by a differently-cased +// key: IAM compares tag keys case-insensitively, so the tag is removed even +// though the supplied key does not match the stored casing. +func IAMUntagUser_case_insensitive_key(s *S3Conf) error { + testName := "IAMUntagUser_case_insensitive_key" + return iamActionHandler(s, testName, func(client *iam.Client) error { + userName := newIAMUserName() + if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil { + return err + } + + checkErr := func() error { + if _, err := tagIAMUser(client, &iam.TagUserInput{ + UserName: &userName, + Tags: iamTagList(map[string]string{"env": "prod"}), + }); err != nil { + return err + } + if _, err := untagIAMUser(client, &iam.UntagUserInput{ + UserName: &userName, + TagKeys: []string{"EnV"}, + }); err != nil { + return err + } + return checkIAMUserTags(client, userName, map[string]string{}) + }() + + deleteErr := deleteIAMUser(client, userName) + if checkErr != nil { + return checkErr + } + return deleteErr + }) +} + +func untagIAMUser(client *iam.Client, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return client.UntagUser(ctx, input) +} diff --git a/tests/integration/iam_update_assume_role_policy.go b/tests/integration/iam_update_assume_role_policy.go index 2935e0f5..1cbc7862 100644 --- a/tests/integration/iam_update_assume_role_policy.go +++ b/tests/integration/iam_update_assume_role_policy.go @@ -88,10 +88,10 @@ func IAMUpdateAssumeRolePolicy_long_role_name(s *S3Conf) error { testName := "IAMUpdateAssumeRolePolicy_long_role_name" return iamActionHandler(s, testName, func(client *iam.Client) error { _, err := updateIAMAssumeRolePolicy(client, &iam.UpdateAssumeRolePolicyInput{ - RoleName: aws.String(strings.Repeat("a", 129)), + RoleName: aws.String(strings.Repeat("a", 65)), PolicyDocument: aws.String(validTrustPolicyDocument), }) - return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 128)) + return checkIAMApiErr(err, iamerr.UserNameTooLong("roleName", 64)) }) } diff --git a/webui/web/iam-oidc.html b/webui/web/iam-oidc.html index b4ce1db1..157330b2 100644 --- a/webui/web/iam-oidc.html +++ b/webui/web/iam-oidc.html @@ -219,7 +219,7 @@ under the License.
-

Tags are set at creation only.

+

Optional. Tags can also be added, changed and removed later from the provider’s Manage view.

@@ -239,7 +239,7 @@ under the License.

Provider

-

URL and tags are fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.

+

The URL is fixed at creation. Client IDs change one at a time; thumbprints are replaced as a whole list.

-
-
Tags
-
-
-
+ +
+
+
+

Tags

+

Key/value labels, also readable from policy conditions.

+
+ +
+
+
+

Audiences / Client IDs

@@ -360,6 +368,7 @@ under the License.