mirror of
https://github.com/versity/versitygw.git
synced 2026-08-29 20:26:56 +00:00
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:
+88
-8
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
maxTagValLen = 256
|
||||
MaxTagMembersPerRequest = 50
|
||||
|
||||
MaxRoleNameLen = 64
|
||||
roleIDPrefix = "AROA"
|
||||
roleIDRandomLen = 17
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+78
-11
@@ -230,7 +230,7 @@ under the License.
|
||||
<button type="button" onclick="iamAddTagRow('create-role-tags')" class="px-3 py-1.5 text-xs border border-gray-200 hover:bg-gray-50 text-charcoal rounded-lg transition-colors">Add Tag</button>
|
||||
</div>
|
||||
<div id="create-role-tags" class="space-y-2"></div>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only.</p>
|
||||
<p class="mt-2 text-xs text-charcoal-300">Optional. Tags can also be added, changed and removed later from the role’s Manage view.</p>
|
||||
</div>
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
|
||||
@@ -302,13 +302,21 @@ under the License.
|
||||
<dt class="text-charcoal-300" title="Set at creation, not editable">Description</dt>
|
||||
<dd id="role-detail-description" class="mt-1 text-charcoal">-</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-charcoal-300">Tags</dt>
|
||||
<dd id="role-detail-tags" class="mt-1 flex flex-wrap gap-2">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-charcoal">Tags</h3>
|
||||
<p id="role-tag-quota-note" class="text-xs text-charcoal-300 mt-1">Key/value labels, also readable from policy conditions.</p>
|
||||
</div>
|
||||
<button id="edit-role-tags-btn" onclick="openRoleTagEditor()" class="px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors">Edit Tags</button>
|
||||
</div>
|
||||
<div id="role-tags" class="border border-gray-100 rounded-lg p-4 flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Trust policy -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -382,6 +390,7 @@ under the License.
|
||||
let nextMarker = null;
|
||||
let currentRole = null;
|
||||
let rolePolicySizes = {};
|
||||
let currentRoleTags = []; // the open role's tags, as [{Key, Value}]
|
||||
let roleToDelete = null;
|
||||
let pendingRoleDetails = null; // step 1 of the create wizard
|
||||
|
||||
@@ -559,10 +568,12 @@ under the License.
|
||||
async function openManageRoleModal(roleName) {
|
||||
currentRole = allRoles.find(r => r.RoleName === roleName) || { RoleName: roleName };
|
||||
rolePolicySizes = {};
|
||||
currentRoleTags = [];
|
||||
|
||||
document.getElementById('manage-role-title').textContent = roleName;
|
||||
renderRoleDetails(currentRole);
|
||||
openModal('manage-role-modal');
|
||||
loadRoleTags();
|
||||
|
||||
// Refresh from the server so the trust document is current
|
||||
try {
|
||||
@@ -586,12 +597,6 @@ under the License.
|
||||
: '-';
|
||||
document.getElementById('role-detail-description').textContent = role.Description || '-';
|
||||
|
||||
const tagsEl = document.getElementById('role-detail-tags');
|
||||
const tags = Array.isArray(role.Tags) ? role.Tags : (role.Tags ? [role.Tags] : []);
|
||||
tagsEl.innerHTML = tags.length === 0
|
||||
? '<span class="text-charcoal-300">-</span>'
|
||||
: tags.map(tag => `<span class="px-2 py-0.5 bg-gray-100 text-charcoal text-xs font-mono rounded">${escapeHtml(tag.Key)}=${escapeHtml(tag.Value || '')}</span>`).join('');
|
||||
|
||||
const preview = document.getElementById('role-trust-preview');
|
||||
const trust = role.AssumeRolePolicyDocument || '';
|
||||
if (!trust) {
|
||||
@@ -605,6 +610,68 @@ under the License.
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Manage: tags
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* ListRoleTags is its own permission, so this loads the tags rather than
|
||||
* reusing whatever GetRole happened to return — and a denial disables
|
||||
* editing in place instead of failing the whole modal.
|
||||
*/
|
||||
async function loadRoleTags() {
|
||||
const el = document.getElementById('role-tags');
|
||||
el.innerHTML = '<span class="text-sm text-charcoal-300">Loading...</span>';
|
||||
try {
|
||||
currentRoleTags = [];
|
||||
let marker = null;
|
||||
do {
|
||||
const page = await api.iamListRoleTags(currentRole.RoleName, { marker: marker || undefined });
|
||||
currentRoleTags = currentRoleTags.concat(page.tags);
|
||||
marker = page.isTruncated ? page.marker : null;
|
||||
} while (marker);
|
||||
|
||||
el.innerHTML = iamTagChips(currentRoleTags);
|
||||
setEditRoleTagsEnabled(true);
|
||||
updateRoleTagQuotaNote();
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error);
|
||||
setEditRoleTagsEnabled(false);
|
||||
el.innerHTML = iamIsAccessDenied(error)
|
||||
? '<span class="text-sm text-charcoal-300">You don\u2019t have permission to list this role\u2019s tags</span>'
|
||||
: `<span class="text-sm text-charcoal-300">Error loading tags: ${escapeHtml(iamShortError(error))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditRoleTagsEnabled(enabled) {
|
||||
const button = document.getElementById('edit-role-tags-btn');
|
||||
button.disabled = !enabled;
|
||||
button.className = enabled
|
||||
? 'px-3 py-1.5 text-xs border border-accent text-accent hover:bg-accent-50 font-medium rounded-lg transition-colors'
|
||||
: 'px-3 py-1.5 text-xs border border-gray-200 text-charcoal-300 rounded-lg opacity-50 cursor-not-allowed';
|
||||
}
|
||||
|
||||
function updateRoleTagQuotaNote() {
|
||||
document.getElementById('role-tag-quota-note').textContent =
|
||||
`${currentRoleTags.length} / ${IAM_LIMITS.tagsPerResource} tags. Also readable from policy conditions.`;
|
||||
}
|
||||
|
||||
function openRoleTagEditor() {
|
||||
iamTagEditor.open({
|
||||
title: 'Edit Tags',
|
||||
subtitle: `Role ${currentRole.RoleName}`,
|
||||
tags: currentRoleTags,
|
||||
onSave: async ({ set, remove }) => {
|
||||
// Removals first: they free room under the 50-tag cap for whatever
|
||||
// this same edit is adding.
|
||||
if (remove.length) await api.iamUntagRole(currentRole.RoleName, remove);
|
||||
if (set.length) await api.iamTagRole(currentRole.RoleName, set);
|
||||
showToast('Tags updated successfully', 'success');
|
||||
loadRoleTags();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openTrustPolicyEditor() {
|
||||
if (!currentRole) return;
|
||||
let documentText = currentRole.AssumeRolePolicyDocument || '';
|
||||
|
||||
@@ -2631,6 +2631,36 @@ ${tagsXml}
|
||||
await this.iamRequest('UpdateAssumeRolePolicy', { RoleName: roleName, PolicyDocument: policyDocument });
|
||||
}
|
||||
|
||||
// ---- Role tags ----
|
||||
|
||||
/**
|
||||
* Add or replace tags on a role. A key already present is overwritten
|
||||
* rather than duplicated, so this doubles as the edit path.
|
||||
*/
|
||||
async iamTagRole(roleName, tags) {
|
||||
const params = { RoleName: roleName };
|
||||
this.flattenTags(params, tags);
|
||||
await this.iamRequest('TagRole', params);
|
||||
}
|
||||
|
||||
async iamUntagRole(roleName, tagKeys) {
|
||||
const params = { RoleName: roleName };
|
||||
this.flattenMemberList(params, 'TagKeys', tagKeys);
|
||||
await this.iamRequest('UntagRole', params);
|
||||
}
|
||||
|
||||
async iamListRoleTags(roleName, options = {}) {
|
||||
const params = { RoleName: roleName };
|
||||
if (options.marker) params.Marker = options.marker;
|
||||
if (options.maxItems) params.MaxItems = options.maxItems;
|
||||
const result = await this.iamRequest('ListRoleTags', params);
|
||||
return {
|
||||
tags: iamAsArray(result.Tags),
|
||||
isTruncated: result.IsTruncated === 'true',
|
||||
marker: result.Marker || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles echo their trust policy percent-encoded on create/get/list
|
||||
*/
|
||||
|
||||
@@ -173,7 +173,7 @@ function iamValidatePath(path) {
|
||||
*/
|
||||
function iamValidateTags(tags) {
|
||||
if (tags.length > IAM_LIMITS.tagsPerResource) {
|
||||
return `A user can carry ${IAM_LIMITS.tagsPerResource} tags at most.`;
|
||||
return `A single resource can carry ${IAM_LIMITS.tagsPerResource} tags at most.`;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
@@ -697,8 +697,8 @@ const iamPolicyEditor = {
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Edit a user's whole tag set at once, then apply it as the minimal pair of
|
||||
* API calls: one UntagUser for the keys that disappeared, one TagUser for
|
||||
* Edit an identity's whole tag set at once, then apply it as the minimal
|
||||
* pair of API calls: one untag for the keys that disappeared, one tag for
|
||||
* the ones added or changed. Editing the set as a whole — rather than a
|
||||
* tag at a time — is what lets a rename, a couple of additions and a couple
|
||||
* of removals be one reviewable Save.
|
||||
@@ -730,7 +730,7 @@ const iamTagEditor = {
|
||||
<svg class="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div class="text-sm text-blue-800">
|
||||
<p class="font-medium">About Tags</p>
|
||||
<p class="mt-1">Key/value labels for grouping and search. They are also readable from policy conditions as <code class="font-mono">aws:PrincipalTag/<key></code> for the tagged user and <code class="font-mono">aws:ResourceTag/<key></code> for the user being acted on. Keys are case insensitive; values may be empty.</p>
|
||||
<p class="mt-1">Key/value labels for grouping and search. They are also readable from policy conditions as <code class="font-mono">aws:PrincipalTag/<key></code> for the tagged identity and <code class="font-mono">aws:ResourceTag/<key></code> for the identity being acted on. Keys are case insensitive; values may be empty.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -820,8 +820,8 @@ const iamTagEditor = {
|
||||
|
||||
/**
|
||||
* Diff the edited rows against the tags the modal opened with. A key whose
|
||||
* only change is its casing still lands in set: TagUser overwrites the
|
||||
* stored tag in place, taking the new casing with it.
|
||||
* only change is its casing still lands in set: the tag action overwrites
|
||||
* the stored tag in place, taking the new casing with it.
|
||||
*/
|
||||
_diff(current) {
|
||||
const original = this._state.tags || [];
|
||||
|
||||
Reference in New Issue
Block a user