diff --git a/iamapi/controller.go b/iamapi/controller.go index eb042e76..dd337cd0 100644 --- a/iamapi/controller.go +++ b/iamapi/controller.go @@ -624,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 } @@ -708,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 } @@ -767,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 } @@ -790,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 } @@ -819,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) + 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 { @@ -838,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 } @@ -875,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 } @@ -905,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 } @@ -919,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 } diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go index 38e4af80..8a2d8078 100644 --- a/iamapi/controller_test.go +++ b/iamapi/controller_test.go @@ -647,12 +647,12 @@ func TestIAMApiControllerTagUserExceedsQuota(t *testing.T) { } atQuota := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}} - for i := 1; i <= storage.MaxTagsPerUser; i++ { + 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.MaxTagsPerUser, resp.StatusCode, readBody(t, resp)) + 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. @@ -1469,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 diff --git a/iamapi/internal/iammiddleware/policy.go b/iamapi/internal/iammiddleware/policy.go index 70511f27..6201fd35 100644 --- a/iamapi/internal/iammiddleware/policy.go +++ b/iamapi/internal/iammiddleware/policy.go @@ -180,7 +180,8 @@ func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string 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 @@ -381,9 +382,9 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri } switch action { - case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider", "TagUser": + case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider", "TagUser", "TagRole": addRequestTagContext(condCtx, ctx) - case "UntagUser": + case "UntagUser", "UntagRole": addTagKeysContext(condCtx, ctx) } @@ -471,9 +472,9 @@ func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) { condCtx["aws:TagKeys"] = keys } -// addTagKeysContext populates aws:TagKeys from UntagUser's TagKeys -// parameter. UntagUser supplies keys without values, so aws:TagKeys is the -// only tag key it can be scoped by +// 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 { diff --git a/iamapi/internal/iamutil/user.go b/iamapi/internal/iamutil/user.go index a9db7151..0f3a8d2c 100644 --- a/iamapi/internal/iamutil/user.go +++ b/iamapi/internal/iamutil/user.go @@ -43,6 +43,7 @@ const ( maxTagValLen = 256 MaxTagMembersPerRequest = 50 + MaxRoleNameLen = 64 roleIDPrefix = "AROA" roleIDRandomLen = 17 diff --git a/iamapi/router.go b/iamapi/router.go index 8453d7d4..48620acb 100644 --- a/iamapi/router.go +++ b/iamapi/router.go @@ -86,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, diff --git a/iamapi/storage/common.go b/iamapi/storage/common.go index 7969b9aa..4858067e 100644 --- a/iamapi/storage/common.go +++ b/iamapi/storage/common.go @@ -28,9 +28,9 @@ import ( // user may hold at once, matching the AWS IAM quota. const MaxAccessKeysPerUser = 2 -// MaxTagsPerUser is the maximum number of tags a single IAM user may carry -// at once, matching the AWS IAM quota. -const MaxTagsPerUser = 50 +// MaxTagsPerResource is the maximum number of tags a single IAM user or +// role 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 @@ -89,7 +89,9 @@ type ListUserTagsInput struct { MaxItems int32 } -type ListUserTagsOutput struct { +// ListTagsOutput is the paginated tag window ListUserTags and ListRoleTags +// both return. +type ListTagsOutput struct { Tags []types.Tag IsTruncated bool Marker string @@ -188,15 +190,22 @@ type ListRolePoliciesOutput struct { Marker string } +type ListRoleTagsInput struct { + RoleName string + Marker string + MaxItems int32 +} + type ListOIDCProvidersOutput struct { Providers []types.OpenIDConnectProviderListEntry } -// mergeTags applies TagUser's merge semantics to existing: an incoming tag -// replaces the existing tag whose key matches case-insensitively — 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 user already at the cap is allowed. +// mergeTags applies the tag actions' merge semantics to existing: an +// incoming tag replaces the existing tag whose key matches +// case-insensitively — 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) ([]types.Tag, error) { merged := slices.Clone(existing) for _, tag := range incoming { @@ -204,7 +213,7 @@ func mergeTags(existing, incoming []types.Tag) ([]types.Tag, error) { merged[idx] = tag continue } - if len(merged) >= MaxTagsPerUser { + if len(merged) >= MaxTagsPerResource { return nil, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded) } merged = append(merged, tag) @@ -212,9 +221,9 @@ func mergeTags(existing, incoming []types.Tag) ([]types.Tag, error) { return merged, nil } -// removeTags applies UntagUser's removal semantics to existing: every tag -// whose key case-insensitively matches one of tagKeys is dropped, and a key -// naming no existing tag is ignored rather than reported. +// removeTags applies the untag actions' removal semantics to existing: +// every tag whose key case-insensitively matches one of tagKeys is dropped, +// and a key naming no existing tag is ignored rather than reported. func removeTags(existing []types.Tag, tagKeys []string) []types.Tag { return slices.DeleteFunc(slices.Clone(existing), func(tag types.Tag) bool { return slices.ContainsFunc(tagKeys, func(key string) bool { @@ -229,31 +238,31 @@ func indexOfTagKey(tags []types.Tag, key string) int { }) } -// paginateTags sorts tags by key and applies input's Marker/MaxItems window. -// AWS's own ListUserTags returns tags in an unspecified order (its docs +// 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, input ListUserTagsInput) *ListUserTagsOutput { +func paginateTags(tags []types.Tag, marker string, maxItems int32) *ListTagsOutput { sorted := slices.Clone(tags) slices.SortFunc(sorted, func(a, b types.Tag) int { return strings.Compare(a.Key, b.Key) }) - if input.Marker != "" { + if marker != "" { start := len(sorted) - if idx := indexOfTagKey(sorted, input.Marker); idx >= 0 { + if idx := indexOfTagKey(sorted, marker); idx >= 0 { start = idx + 1 } sorted = sorted[start:] } limit := len(sorted) - if input.MaxItems > 0 && int(input.MaxItems) < limit { - limit = int(input.MaxItems) + if maxItems > 0 && int(maxItems) < limit { + limit = int(maxItems) } - out := &ListUserTagsOutput{Tags: sorted[:limit]} + out := &ListTagsOutput{Tags: sorted[:limit]} if limit < len(sorted) { out.IsTruncated = true out.Marker = out.Tags[limit-1].Key diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go index f3f0ad4d..7f8cdb6e 100644 --- a/iamapi/storage/internal.go +++ b/iamapi/storage/internal.go @@ -391,7 +391,7 @@ func (s *InternalStore) updateUserTags(userName string, mutate func(*types.User) return unwrapAPIError(err) } -func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error) { +func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput) (*ListTagsOutput, error) { s.RLock() defer s.RUnlock() @@ -405,7 +405,7 @@ func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput) return nil, iamerr.NoSuchEntityUser(input.UserName) } - return paginateTags(user.Tags, input), nil + return paginateTags(user.Tags, input.Marker, input.MaxItems), nil } func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { @@ -957,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) + 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) + 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), nil +} + func (s *InternalStore) PutRolePolicy(_ context.Context, input PutRolePolicyInput) error { s.Lock() defer s.Unlock() diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go index c5be9414..fee63758 100644 --- a/iamapi/storage/storer.go +++ b/iamapi/storage/storer.go @@ -34,7 +34,7 @@ type Storer interface { 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) (*ListUserTagsOutput, 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 @@ -59,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 diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go index 2a56dacc..0ea29eb1 100644 --- a/iamapi/storage/vault.go +++ b/iamapi/storage/vault.go @@ -663,13 +663,13 @@ func (s *VaultStore) UntagUser(ctx context.Context, userName string, tagKeys []s return err } -func (s *VaultStore) ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error) { +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), nil + return paginateTags(user.Tags, input.Marker, input.MaxItems), nil } func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) { @@ -1202,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) + 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) + 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), 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) 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/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 efbbe55a..23605f39 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1399,6 +1399,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) @@ -1695,6 +1742,9 @@ func TestIAM(ts *TestState) { TestIAMListRoles(ts) TestIAMDeleteRole(ts) TestIAMUpdateAssumeRolePolicy(ts) + TestIAMTagRole(ts) + TestIAMUntagRole(ts) + TestIAMListRoleTags(ts) TestIAMPutRolePolicy(ts) TestIAMGetRolePolicy(ts) TestIAMDeleteRolePolicy(ts) @@ -2327,6 +2377,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, 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_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_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 index 23287144..62bd89cb 100644 --- a/tests/integration/iam_tag_user.go +++ b/tests/integration/iam_tag_user.go @@ -246,7 +246,7 @@ func IAMTagUser_tag_limit_exceeded(s *S3Conf) error { checkErr := func() error { if _, err := tagIAMUser(client, &iam.TagUserInput{ UserName: &userName, - Tags: numberedIAMTags(1, storage.MaxTagsPerUser), + Tags: numberedIAMTags(1, storage.MaxTagsPerResource), }); err != nil { return err } 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_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-roles.html b/webui/web/iam-roles.html index 54f6b854..cf6fb2c2 100644 --- a/webui/web/iam-roles.html +++ b/webui/web/iam-roles.html @@ -230,7 +230,7 @@ under the License.
-Tags are set at creation only.
+Optional. Tags can also be added, changed and removed later from the role’s Manage view.
Key/value labels, also readable from policy conditions.
+About Tags
-Key/value labels for grouping and search. They are also readable from policy conditions as aws:PrincipalTag/<key> for the tagged user and aws:ResourceTag/<key> for the user being acted on. Keys are case insensitive; values may be empty.
Key/value labels for grouping and search. They are also readable from policy conditions as aws:PrincipalTag/<key> for the tagged identity and aws:ResourceTag/<key> for the identity being acted on. Keys are case insensitive; values may be empty.