feat: add IAM role tagging actions

Adds `TagRole`, `UntagRole` and `ListRoleTags` to the standalone IAM service, backed by both the internal and Vault storers, with the same semantics the user tagging actions already have: tag keys are matched case-insensitively but stored case-preserving, TagRole merges into the role's existing tags and rejects duplicate keys, UntagRole removal is idempotent, and ListRoleTags is sorted by key and paginated. The per-request member count and the per-role tag total are enforced as separate quotas, so replacing a tag on a role already at the 50-tag cap still succeeds. Role tags and the tags of a user sharing the same name are independent sets.

All three actions are authorized against the target role's ARN, so `aws:ResourceTag/<key>` reads the role's own tags, and TagRole and UntagRole populate `aws:RequestTag/<key>` and `aws:TagKeys` respectively, so a tag-scoped policy Condition governs which tags a caller may set or remove.

The tag storage helpers are now shared between users and roles: `MaxTagsPerUser` becomes `MaxTagsPerResource`, `ListUserTagsOutput` becomes `ListTagsOutput`, and `paginateTags` takes the marker and page size directly instead of a user-specific input struct.

The WebGUI gains a Tags section in the IAM role manage view, reusing the tag editor the user view already uses, which applies a whole edited tag set as a single UntagRole and TagRole pair.

Also corrects the `roleName` length bound across every role action: it was validated against the 128-character user-lookup limit, where IAM caps role names at 64.
This commit is contained in:
niksis02
2026-08-28 00:49:20 +04:00
parent 26a54b33e9
commit 1bbcd64195
22 changed files with 1947 additions and 82 deletions
+88 -8
View File
@@ -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
}
+446 -2
View File
@@ -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
+7 -6
View File
@@ -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 {
+1
View File
@@ -43,6 +43,7 @@ const (
maxTagValLen = 256
MaxTagMembersPerRequest = 50
MaxRoleNameLen = 64
roleIDPrefix = "AROA"
roleIDRandomLen = 17
+4
View File
@@ -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,
+30 -21
View File
@@ -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
+63 -2
View File
@@ -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()
+5 -1
View File
@@ -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
+31 -2
View File
@@ -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)
+34
View File
@@ -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"`
}