feat: add IAM user tagging actions

Adds `TagUser`, `UntagUser` and `ListUserTags` to the standalone IAM service, backed by both the internal and Vault storers. Tag keys are matched case-insensitively but stored case-preserving, TagUser merges into the user's existing tags and rejects duplicate keys, UntagUser removal is idempotent, and ListUserTags is sorted by key and paginated. The per-request member count and the per-user tag total are enforced as separate quotas.

All three actions are authorized against the target user's ARN, and TagUser and UntagUser populate aws:RequestTag/<key> and aws:TagKeys respectively, so a tag-scoped policy Condition governs which tags a caller may set or remove.

The WebGUI gains a Tags section in the IAM user manage view, with an editor that applies a whole edited tag set as a single UntagUser and TagUser pair.

Also corrects two error shapes that never matched AWS: a half-supplied tag member now reports a ValidationError naming the member field instead of MissingParameter, and the maxItems bound check reports separate lower- and upper-bound errors across every IAM list action.
This commit is contained in:
niksis02
2026-08-28 00:49:20 +04:00
parent abb3b27149
commit 26a54b33e9
23 changed files with 2461 additions and 230 deletions
+82
View File
@@ -235,6 +235,88 @@ func (c IAMApiController) UpdateUser(ctx fiber.Ctx) (*Response, error) {
}}, nil
}
// TagUser adds or replaces tags on an existing user. AWS validates the
// request in full before it ever looks the user up, so a malformed tag on a
// non-existent user reports the tag error, not NoSuchEntity.
func (c IAMApiController) TagUser(ctx fiber.Ctx) (*Response, error) {
userName, err := iamutil.GetUserName(ctx, "TagUser", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
if err != nil {
return nil, err
}
tags, err := iamutil.ParseTags(ctx)
if err != nil {
return nil, err
}
if len(tags) == 0 {
debuglogger.Logf("missing required TagUser parameter: Tags")
return nil, iamerr.MissingValue("tags")
}
if err := c.store.TagUser(ctx.Context(), userName, tags); err != nil {
debuglogger.Logf("failed to tag IAM user %q: %v", userName, err)
return nil, err
}
return &Response{Data: &types.TagUserResponse{}}, nil
}
// UntagUser removes the named tags from an existing user. Removal is
// idempotent: a key naming no current tag is not an error.
func (c IAMApiController) UntagUser(ctx fiber.Ctx) (*Response, error) {
userName, err := iamutil.GetUserName(ctx, "UntagUser", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
if err != nil {
return nil, err
}
tagKeys, err := iamutil.ParseTagKeys(ctx)
if err != nil {
return nil, err
}
if len(tagKeys) == 0 {
debuglogger.Logf("missing required UntagUser parameter: TagKeys")
return nil, iamerr.MissingValue("tagKeys")
}
if err := c.store.UntagUser(ctx.Context(), userName, tagKeys); err != nil {
debuglogger.Logf("failed to untag IAM user %q: %v", userName, err)
return nil, err
}
return &Response{Data: &types.UntagUserResponse{}}, nil
}
func (c IAMApiController) ListUserTags(ctx fiber.Ctx) (*Response, error) {
userName, err := iamutil.GetUserName(ctx, "ListUserTags", iamutil.MaxUserLookupLen, iamerr.MissingValue("userName"))
if err != nil {
return nil, err
}
maxItems, err := iamutil.ParseMaxItems(ctx, "ListUserTags")
if err != nil {
return nil, err
}
marker, _ := iamutil.RequestParam(ctx, "Marker")
out, err := c.store.ListUserTags(ctx.Context(), storage.ListUserTagsInput{
UserName: userName,
Marker: marker,
MaxItems: maxItems,
})
if err != nil {
debuglogger.Logf("failed to list IAM user %q tags: %v", userName, err)
return nil, err
}
return &Response{Data: &types.ListUserTagsResponse{
Result: types.ListUserTagsResult{
Tags: types.Tags{Members: out.Tags},
IsTruncated: out.IsTruncated,
Marker: out.Marker,
},
}}, nil
}
func (c IAMApiController) CreateAccessKey(ctx fiber.Ctx) (*Response, error) {
userName, err := iamutil.GetUserName(ctx, "CreateAccessKey", iamutil.MaxUserLookupLen, iamerr.MissingParameter("UserName"))
if err != nil {
+436 -6
View File
@@ -21,6 +21,7 @@ import (
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -364,8 +365,8 @@ func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) {
"Tags.member.1.Value": {"test"},
},
status: http.StatusBadRequest,
code: "MissingParameter",
message: "The request must contain the parameter Tags.member.1.Key.",
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null",
},
{
name: "missing tag value",
@@ -375,8 +376,8 @@ func TestIAMApiControllerCreateUserValidationErrors(t *testing.T) {
"Tags.member.1.Key": {"env"},
},
status: http.StatusBadRequest,
code: "MissingParameter",
message: "The request must contain the parameter Tags.member.1.Value.",
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null",
},
}
@@ -533,6 +534,435 @@ func TestIAMApiControllerUpdateUserAlreadyExists(t *testing.T) {
requireIAMError(t, resp, http.StatusConflict, "Sender", "EntityAlreadyExists", "User with name zoe already exists.")
}
func TestIAMApiControllerUserTagLifecycle(t *testing.T) {
server := newIAMControllerTestServer(t)
create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
if create.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create))
}
if got := listUserTags(t, server, "alice"); len(got.Tags.Members) != 0 || got.IsTruncated {
t.Fatalf("ListUserTags on a fresh user = %#v, want no tags", got)
}
tagUser(t, server, "alice", map[string]string{"env": "prod", "team": "storage", "empty": ""})
got := listUserTags(t, server, "alice")
// Sorted by key, regardless of the order they were added in.
want := []iamtypes.Tag{{Key: "empty", Value: ""}, {Key: "env", Value: "prod"}, {Key: "team", Value: "storage"}}
if !slices.Equal(got.Tags.Members, want) {
t.Fatalf("Tags = %#v, want %#v", got.Tags.Members, want)
}
// A repeated key replaces its value in place; a differently-cased key
// is the same tag, and the new casing wins.
tagUser(t, server, "alice", map[string]string{"env": "staging"})
tagUser(t, server, "alice", map[string]string{"TEAM": "compute"})
got = listUserTags(t, server, "alice")
want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}, {Key: "env", Value: "staging"}}
if !slices.Equal(got.Tags.Members, want) {
t.Fatalf("Tags after overwrite = %#v, want %#v", got.Tags.Members, want)
}
// GetUser reports the same tags the tag actions maintain.
getUser := doIAMAction(t, server, url.Values{"Action": {"GetUser"}, "UserName": {"alice"}})
var getResp struct {
Result struct{ User iamtypes.User } `xml:"GetUserResult"`
}
unmarshalXML(t, readBody(t, getUser), &getResp)
if len(getResp.Result.User.Tags) != 3 {
t.Fatalf("GetUser Tags = %#v, want 3 tags", getResp.Result.User.Tags)
}
// Removal is case-insensitive, and a key naming no tag is not an error.
untag := doIAMAction(t, server, url.Values{
"Action": {"UntagUser"},
"UserName": {"alice"},
"TagKeys.member.1": {"EnV"},
"TagKeys.member.2": {"never-existed"},
})
if untag.StatusCode != http.StatusOK {
t.Fatalf("UntagUser status = %d, body=%s", untag.StatusCode, readBody(t, untag))
}
got = listUserTags(t, server, "alice")
want = []iamtypes.Tag{{Key: "TEAM", Value: "compute"}, {Key: "empty", Value: ""}}
if !slices.Equal(got.Tags.Members, want) {
t.Fatalf("Tags after untag = %#v, want %#v", got.Tags.Members, want)
}
}
func TestIAMApiControllerListUserTagsPagination(t *testing.T) {
server := newIAMControllerTestServer(t)
create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
if create.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create))
}
tagUser(t, server, "alice", map[string]string{"a": "1", "b": "2", "c": "3"})
var seen []iamtypes.Tag
marker := ""
for page := 1; ; page++ {
params := url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"1"}}
if marker != "" {
params.Set("Marker", marker)
}
resp := doIAMAction(t, server, params)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ListUserTags status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out struct {
Result iamtypes.ListUserTagsResult `xml:"ListUserTagsResult"`
}
unmarshalXML(t, readBody(t, resp), &out)
if len(out.Result.Tags.Members) != 1 {
t.Fatalf("page %d holds %d tags, want 1", page, len(out.Result.Tags.Members))
}
seen = append(seen, out.Result.Tags.Members...)
if !out.Result.IsTruncated {
if out.Result.Marker != "" {
t.Fatalf("final page Marker = %q, want empty", out.Result.Marker)
}
break
}
marker = out.Result.Marker
}
want := []iamtypes.Tag{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}}
if !slices.Equal(seen, want) {
t.Fatalf("paged tags = %#v, want %#v", seen, want)
}
}
func TestIAMApiControllerTagUserExceedsQuota(t *testing.T) {
server := newIAMControllerTestServer(t)
create := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
if create.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", create.StatusCode, readBody(t, create))
}
atQuota := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}}
for i := 1; i <= storage.MaxTagsPerUser; 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))
}
// Replacing an existing key at the quota is fine: the total doesn't grow.
replace := doIAMAction(t, server, url.Values{
"Action": {"TagUser"}, "UserName": {"alice"},
"Tags.member.1.Key": {"k1"}, "Tags.member.1.Value": {"replaced"},
})
if replace.StatusCode != http.StatusOK {
t.Fatalf("TagUser replacing at quota status = %d, body=%s", replace.StatusCode, readBody(t, replace))
}
// One more distinct key does not fit.
overflow := doIAMAction(t, server, url.Values{
"Action": {"TagUser"}, "UserName": {"alice"},
"Tags.member.1.Key": {"overflow"}, "Tags.member.1.Value": {"x"},
})
requireIAMError(t, overflow, http.StatusConflict, "Sender", "LimitExceeded",
"The number of tags has reached the maximum limit.")
}
func TestIAMApiControllerUserTagValidationErrors(t *testing.T) {
tooManyTags := url.Values{"Action": {"TagUser"}, "UserName": {"alice"}}
for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ {
tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Key", i), fmt.Sprintf("k%d", i))
tooManyTags.Set(fmt.Sprintf("Tags.member.%d.Value", i), fmt.Sprintf("v%d", i))
}
tooManyTagKeys := url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}}
for i := 1; i <= iamutil.MaxTagMembersPerRequest+1; i++ {
tooManyTagKeys.Set(fmt.Sprintf("TagKeys.member.%d", i), fmt.Sprintf("k%d", i))
}
tests := []struct {
name string
setupUser bool
params url.Values
status int
code string
message string
}{
{
name: "tag missing user name",
params: url.Values{"Action": {"TagUser"}, "Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
},
{
name: "tag missing tags",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must not be null",
},
{
name: "tag missing key",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must not be null",
},
{
name: "tag missing value",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"env"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must not be null",
},
{
name: "tag empty key",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {""}, "Tags.member.1.Value": {"prod"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length greater than or equal to 1",
},
{
name: "tag key too long",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {strings.Repeat("k", 129)}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must have length less than or equal to 128",
},
{
name: "tag invalid key characters",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: `1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`,
},
{
name: "tag value too long",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {strings.Repeat("v", 257)}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must have length less than or equal to 256",
},
{
name: "tag invalid value characters",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"alice"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"bad*value"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: `1 validation error detected: Value at 'tags.1.member.value' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]*`,
},
{
name: "tag duplicate keys",
setupUser: true,
params: url.Values{
"Action": {"TagUser"}, "UserName": {"alice"},
"Tags.member.1.Key": {"env"}, "Tags.member.1.Value": {"a"},
"Tags.member.2.Key": {"ENV"}, "Tags.member.2.Value": {"b"},
},
status: http.StatusBadRequest,
code: "InvalidInput",
message: "Duplicate tag keys found. Please note that Tag keys are case insensitive.",
},
{
name: "tag too many tags",
setupUser: true,
params: tooManyTags,
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50",
},
{
name: "tag invalid user name characters",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"bad!name"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "The specified value for userName is invalid. It must contain only alphanumeric characters and/or the following: +=,.@_-",
},
{
name: "tag user name too long",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {strings.Repeat("u", 129)}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must have length less than or equal to 128",
},
{
// A malformed tag is reported before the user is looked up.
name: "tag non existing user with invalid tag",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"nosuchuser"}, "Tags.member.1.Key": {"bad*key"}, "Tags.member.1.Value": {"v"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: `1 validation error detected: Value at 'tags.1.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+`,
},
{
name: "tag non existing user",
setupUser: true,
params: url.Values{"Action": {"TagUser"}, "UserName": {"nosuchuser"}, "Tags.member.1.Key": {"k"}, "Tags.member.1.Value": {"v"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The user with name nosuchuser cannot be found.",
},
{
name: "untag missing user name",
params: url.Values{"Action": {"UntagUser"}, "TagKeys.member.1": {"env"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
},
{
name: "untag missing tag keys",
setupUser: true,
params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must not be null",
},
{
name: "untag empty key",
setupUser: true,
params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {""}},
status: http.StatusBadRequest,
code: "ValidationError",
message: invalidTagKeysMessage,
},
{
name: "untag key too long",
setupUser: true,
params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {strings.Repeat("k", 129)}},
status: http.StatusBadRequest,
code: "ValidationError",
message: invalidTagKeysMessage,
},
{
name: "untag invalid key characters",
setupUser: true,
params: url.Values{"Action": {"UntagUser"}, "UserName": {"alice"}, "TagKeys.member.1": {"bad*key"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: invalidTagKeysMessage,
},
{
name: "untag too many tag keys",
setupUser: true,
params: tooManyTagKeys,
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50",
},
{
name: "untag non existing user",
setupUser: true,
params: url.Values{"Action": {"UntagUser"}, "UserName": {"nosuchuser"}, "TagKeys.member.1": {"env"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The user with name nosuchuser cannot be found.",
},
{
name: "list missing user name",
params: url.Values{"Action": {"ListUserTags"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'userName' failed to satisfy constraint: Member must not be null",
},
{
name: "list max items too small",
setupUser: true,
params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"0"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1",
},
{
name: "list max items too large",
setupUser: true,
params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"1001"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000",
},
{
name: "list max items not a number",
setupUser: true,
params: url.Values{"Action": {"ListUserTags"}, "UserName": {"alice"}, "MaxItems": {"abc"}},
status: http.StatusBadRequest,
code: "MalformedInput",
message: "",
},
{
name: "list non existing user",
setupUser: true,
params: url.Values{"Action": {"ListUserTags"}, "UserName": {"nosuchuser"}},
status: http.StatusNotFound,
code: "NoSuchEntity",
message: "The user with name nosuchuser cannot be found.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := newIAMControllerTestServer(t)
if tt.setupUser {
resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
resp := doIAMAction(t, server, tt.params)
requireIAMError(t, resp, tt.status, "Sender", tt.code, tt.message)
})
}
}
// invalidTagKeysMessage is UntagUser's single response to every malformed
// TagKeys member, whatever the specific constraint that failed.
const invalidTagKeysMessage = `1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 128, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [\p{L}\p{Z}\p{N}_.:/=+\-@]+, Member must not be null]`
func tagUser(t *testing.T, server *IAMApiServer, userName string, tags map[string]string) {
t.Helper()
params := url.Values{"Action": {"TagUser"}, "UserName": {userName}}
i := 1
for key, value := range tags {
params.Set(fmt.Sprintf("Tags.member.%d.Key", i), key)
params.Set(fmt.Sprintf("Tags.member.%d.Value", i), value)
i++
}
resp := doIAMAction(t, server, params)
if resp.StatusCode != http.StatusOK {
t.Fatalf("TagUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
func listUserTags(t *testing.T, server *IAMApiServer, userName string) iamtypes.ListUserTagsResult {
t.Helper()
resp := doIAMAction(t, server, url.Values{"Action": {"ListUserTags"}, "UserName": {userName}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("ListUserTags status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out struct {
Result iamtypes.ListUserTagsResult `xml:"ListUserTagsResult"`
}
unmarshalXML(t, readBody(t, resp), &out)
return out.Result
}
func TestIAMApiControllerUserPolicyLifecycle(t *testing.T) {
server := newIAMControllerTestServer(t)
@@ -803,7 +1233,7 @@ func TestIAMApiControllerUserPolicyValidationErrors(t *testing.T) {
params: url.Values{"Action": {"ListUserPolicies"}, "UserName": {"alice"}, "MaxItems": {"1001"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000",
message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000",
},
}
@@ -1616,7 +2046,7 @@ func TestIAMApiControllerRolePolicyValidationErrors(t *testing.T) {
params: url.Values{"Action": {"ListRolePolicies"}, "RoleName": {"my-role"}, "MaxItems": {"1001"}},
status: http.StatusBadRequest,
code: "ValidationError",
message: "1 validation error detected: Value '1001' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000",
message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000",
},
}
+51 -4
View File
@@ -57,11 +57,16 @@ const (
ErrInvalidContentLength
ErrThrottling
ErrTooManyTags
ErrTooManyTagKeys
ErrInvalidTagKeys
ErrTagLimitExceeded
ErrInvalidPathPrefix
ErrDuplicateTagKeys
ErrInvalidAccessKeyIDChars
ErrDeleteConflict
ErrDeleteConflictPolicies
ErrMaxItemsTooLow
ErrMaxItemsTooHigh
)
type APIError interface {
@@ -221,6 +226,36 @@ var errorCodeResponse = map[ErrorCode]Error{
Message: "1 validation error detected: Value at 'tags' failed to satisfy constraint: Member must have length less than or equal to 50",
HTTPStatusCode: http.StatusBadRequest,
},
ErrTooManyTagKeys: {
Type: TypeSender,
Code: "ValidationError",
Message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must have length less than or equal to 50",
HTTPStatusCode: http.StatusBadRequest,
},
ErrInvalidTagKeys: {
Type: TypeSender,
Code: "ValidationError",
Message: "1 validation error detected: Value at 'tagKeys' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 128, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+, Member must not be null]",
HTTPStatusCode: http.StatusBadRequest,
},
ErrTagLimitExceeded: {
Type: TypeSender,
Code: "LimitExceeded",
Message: "The number of tags has reached the maximum limit.",
HTTPStatusCode: http.StatusConflict,
},
ErrMaxItemsTooLow: {
Type: TypeSender,
Code: "ValidationError",
Message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1",
HTTPStatusCode: http.StatusBadRequest,
},
ErrMaxItemsTooHigh: {
Type: TypeSender,
Code: "ValidationError",
Message: "1 validation error detected: Value at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to 1000",
HTTPStatusCode: http.StatusBadRequest,
},
ErrDuplicateTagKeys: {
Type: TypeSender,
Code: "InvalidInput",
@@ -405,10 +440,6 @@ func PathTooLong(field string, maxLength int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at '%s' failed to satisfy constraint: Member must have length less than or equal to %d", field, maxLength))
}
func InvalidMaxItems(value string) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value '%s' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", value))
}
func AccessKeyIDTooShort(minLength int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'accessKeyId' failed to satisfy constraint: Member must have length greater than or equal to %d", minLength))
}
@@ -429,6 +460,22 @@ func InvalidTagKey(index int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.key' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+", index))
}
// MissingTagKey reports a Tags member supplying a Value with no Key.
func MissingTagKey(index int) Error {
return MissingValue(fmt.Sprintf("tags.%d.member.key", index))
}
// MissingTagValue reports a Tags member supplying a Key with no Value. A
// tag value may be empty, but the parameter itself must be present.
func MissingTagValue(index int) Error {
return MissingValue(fmt.Sprintf("tags.%d.member.value", index))
}
// TagKeyTooShort reports an empty tag key
func TagKeyTooShort(index int) Error {
return ValueTooShort(fmt.Sprintf("tags.%d.member.key", index), 1)
}
func TagValueTooLong(index int) Error {
return ValidationError(fmt.Sprintf("1 validation error detected: Value at 'tags.%d.member.value' failed to satisfy constraint: Member must have length less than or equal to 256", index))
}
+21 -7
View File
@@ -173,7 +173,8 @@ func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string
case "GetUser":
return getUserResource(ctx, store)
case "DeleteUser", "UpdateUser", "CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey",
"ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies":
"ListAccessKeys", "PutUserPolicy", "GetUserPolicy", "DeleteUserPolicy", "ListUserPolicies",
"TagUser", "UntagUser", "ListUserTags":
return existingUserResource(ctx, store)
case "GetAccessKeyLastUsed":
return accessKeyOwnerResource(ctx, store)
@@ -380,8 +381,10 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri
}
switch action {
case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider":
case "CreateUser", "CreateRole", "CreateOpenIDConnectProvider", "TagUser":
addRequestTagContext(condCtx, ctx)
case "UntagUser":
addTagKeysContext(condCtx, ctx)
}
return condCtx
@@ -450,11 +453,11 @@ func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) {
// addRequestTagContext populates aws:RequestTag/<key> and aws:TagKeys from
// the request's Tags parameter, parsed the same way the controller parses it
// for the actual create call. A parse failure (e.g. a malformed tag) is left
// unpopulated rather than surfaced here — the controller performs the same
// parse independently and will reject the request with the specific
// tag-validation error afterward, so no create can succeed with tags that
// silently evaded a tag-scoped Condition.
// for the actual create or tag call. A parse failure (e.g. a malformed tag)
// is left unpopulated rather than surfaced here — the controller performs
// the same parse independently and will reject the request with the
// specific tag-validation error afterward, so no write can succeed with
// tags that silently evaded a tag-scoped Condition.
func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) {
tags, err := iamutil.ParseTags(ctx)
if err != nil || len(tags) == 0 {
@@ -468,6 +471,17 @@ 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
func addTagKeysContext(condCtx map[string][]string, ctx fiber.Ctx) {
keys, err := iamutil.ParseTagKeys(ctx)
if err != nil || len(keys) == 0 {
return
}
condCtx["aws:TagKeys"] = keys
}
// CallerArn identifies identity the way real IAM error messages do: the
// user's own Arn, or the assumed-role session Arn.
func CallerArn(identity types.Identity) string {
+63 -21
View File
@@ -29,18 +29,19 @@ import (
)
const (
DefaultAccountID = "000000000000"
DefaultUserPath = "/"
DefaultMaxItems = 100
MaxListItems = 1000
MaxUserNameLen = 64
MaxUserLookupLen = 128
MaxPathLen = 512
userIDPrefix = "AIDA"
userIDRandomLen = 17
userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
maxTagKeyLen = 128
maxTagValLen = 256
DefaultAccountID = "000000000000"
DefaultUserPath = "/"
DefaultMaxItems = 100
MaxListItems = 1000
MaxUserNameLen = 64
MaxUserLookupLen = 128
MaxPathLen = 512
userIDPrefix = "AIDA"
userIDRandomLen = 17
userIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
maxTagKeyLen = 128
maxTagValLen = 256
MaxTagMembersPerRequest = 50
roleIDPrefix = "AROA"
roleIDRandomLen = 17
@@ -184,15 +185,25 @@ func ParseMaxItems(ctx fiber.Ctx, operation string) (int32, error) {
}
parsed, err := strconv.ParseInt(rawMaxItems, 10, 32)
if err != nil || parsed < 1 || parsed > MaxListItems {
debuglogger.Logf("invalid %s MaxItems value %q: parse_error=%v", operation, rawMaxItems, err)
return 0, iamerr.InvalidMaxItems(rawMaxItems)
if err != nil {
debuglogger.Logf("malformed %s MaxItems value %q: %v", operation, rawMaxItems, err)
return 0, iamerr.MalformedInput()
}
if parsed < 1 {
debuglogger.Logf("invalid %s MaxItems value %q", operation, rawMaxItems)
return 0, iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow)
}
if parsed > MaxListItems {
debuglogger.Logf("invalid %s MaxItems value %q", operation, rawMaxItems)
return 0, iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh)
}
return int32(parsed), nil
}
// ParseTags reads IAM tag members from the request (up to 50), validates each, and returns the list.
// ParseTags reads IAM tag members from the request (up to
// MaxTagMembersPerRequest), validates each, and returns the list. Tag keys
// are compared case-insensitively for duplicate detection, matching AWS.
func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
var tags []types.Tag
seen := map[string]struct{}{}
@@ -206,17 +217,17 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
if !hasKey && !hasValue {
break
}
if len(tags) >= 50 {
debuglogger.Logf("IAM user tag count exceeds maximum: max=%d", 50)
if len(tags) >= MaxTagMembersPerRequest {
debuglogger.Logf("IAM tag count exceeds maximum: max=%d", MaxTagMembersPerRequest)
return nil, iamerr.GetAPIError(iamerr.ErrTooManyTags)
}
if !hasKey {
debuglogger.Logf("missing required IAM tag parameter: %s", keyName)
return nil, iamerr.MissingParameter(keyName)
return nil, iamerr.MissingTagKey(i)
}
if !hasValue {
debuglogger.Logf("missing required IAM tag parameter: %s", valueName)
return nil, iamerr.MissingParameter(valueName)
return nil, iamerr.MissingTagValue(i)
}
if err := validateTag(i, key, value); err != nil {
return nil, err
@@ -235,6 +246,33 @@ func ParseTags(ctx fiber.Ctx) ([]types.Tag, error) {
return tags, nil
}
// ParseTagKeys reads UntagUser's TagKeys members from the request (up to
// MaxTagMembersPerRequest), validates each, and returns the list. Unlike
// ParseTags, duplicate keys are accepted: removing the same key twice is a
// no-op, so AWS has no reason to reject it.
func ParseTagKeys(ctx fiber.Ctx) ([]string, error) {
var keys []string
for i := 1; ; i++ {
key, ok := RequestParam(ctx, fmt.Sprintf("TagKeys.member.%d", i))
if !ok {
break
}
if len(keys) >= MaxTagMembersPerRequest {
debuglogger.Logf("IAM tag key count exceeds maximum: max=%d", MaxTagMembersPerRequest)
return nil, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys)
}
if key == "" || len(key) > maxTagKeyLen || !tagKeyPattern.MatchString(key) {
debuglogger.Logf("invalid IAM tag key: index=%d value=%q", i, key)
return nil, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys)
}
keys = append(keys, key)
}
return keys, nil
}
// ValidateName checks that name (an IAM identity or policy name, e.g.
// userName or policyName) is non-empty, matches the allowed character set,
// and fits within maxLength.
@@ -329,7 +367,11 @@ func validateTag(index int, key, value string) error {
debuglogger.Logf("IAM tag key exceeds maximum length: index=%d length=%d max=%d", index, len(key), maxTagKeyLen)
return iamerr.TagKeyTooLong(index)
}
if key == "" || !tagKeyPattern.MatchString(key) {
if key == "" {
debuglogger.Logf("empty IAM tag key: index=%d", index)
return iamerr.TagKeyTooShort(index)
}
if !tagKeyPattern.MatchString(key) {
debuglogger.Logf("invalid IAM tag key: index=%d value=%q", index, key)
return iamerr.InvalidTagKey(index)
}
+4
View File
@@ -65,6 +65,10 @@ func (r *IAMApiRouter) Init() {
"GetUser": r.Ctrl.GetUser,
"ListUsers": r.Ctrl.ListUsers,
"UpdateUser": r.Ctrl.UpdateUser,
// User Tagging
"TagUser": r.Ctrl.TagUser,
"UntagUser": r.Ctrl.UntagUser,
"ListUserTags": r.Ctrl.ListUserTags,
// User Access Key CRUD
"CreateAccessKey": r.Ctrl.CreateAccessKey,
"UpdateAccessKey": r.Ctrl.UpdateAccessKey,
+272
View File
@@ -0,0 +1,272 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package storage
import (
"errors"
"slices"
"strings"
"time"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/iamapi/types"
)
// MaxAccessKeysPerUser is the maximum number of access keys a single IAM
// user may hold at once, matching the AWS IAM quota.
const MaxAccessKeysPerUser = 2
// MaxTagsPerUser is the maximum number of tags a single IAM user may carry
// at once, matching the AWS IAM quota.
const MaxTagsPerUser = 50
// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of
// all of a single IAM user's inline policy documents combined
const MaxInlinePolicyBytesPerUser = 2048
// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of
// all of a single IAM role's inline policy documents combined
const MaxInlinePolicyBytesPerRole = 10240
// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single
// OIDC provider may hold at once
const MaxClientIDsPerOIDCProvider = 100
// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a
// single account may hold
const MaxOIDCProvidersPerAccount = 100
// MaxActiveSessionsPerRole bounds how many currently-unexpired
// AssumeRoleWithWebIdentity sessions a single role may have at once.
// AWS manages and rate-limits STS as a hosted service with no
// customer-visible equivalent quota to match for fidelity; this exists
// purely as local resource protection, since without it a single valid
// federated token can be replayed indefinitely to grow the session
// store — every InternalStore rewrite, or Vault KV path/metadata entry —
// without bound. Chosen generously enough to not constrain any legitimate
// workload's concurrent session count.
//
// A var, not a const, so tests can temporarily lower it rather than paying
// the cost of actually creating 1000 sessions to exercise the cap.
var MaxActiveSessionsPerRole = 1000
var (
ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists")
ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists")
ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists")
// ErrSessionNotFound is returned by GetSession when accessKeyID names no
// session, or names one whose Expiration has already passed.
ErrSessionNotFound = errors.New("iamapi: session not found")
)
type ListUsersInput struct {
PathPrefix string
Marker string
MaxItems int32
}
type ListUsersOutput struct {
Users []types.User
IsTruncated bool
Marker string
}
type ListUserTagsInput struct {
UserName string
Marker string
MaxItems int32
}
type ListUserTagsOutput struct {
Tags []types.Tag
IsTruncated bool
Marker string
}
type UpdateUserInput struct {
UserName string
NewPath string
NewUserName string
NewArn string
}
type CreateAccessKeyInput struct {
UserName string
AccessKeyID string
SecretAccessKey string
Status string
CreateDate time.Time
}
type UpdateAccessKeyInput struct {
UserName string
AccessKeyID string
Status string
}
type ListAccessKeysInput struct {
UserName string
Marker string
MaxItems int32
}
type ListAccessKeysOutput struct {
AccessKeys []types.AccessKeyMetadata
IsTruncated bool
Marker string
}
type GetAccessKeyLastUsedOutput struct {
UserName string
LastUsedDate time.Time
ServiceName string
Region string
}
type PutUserPolicyInput struct {
UserName string
PolicyName string
PolicyDocument string
}
type ListUserPoliciesInput struct {
UserName string
Marker string
MaxItems int32
}
type ListUserPoliciesOutput struct {
PolicyNames []string
IsTruncated bool
Marker string
}
type ListRolesInput struct {
PathPrefix string
Marker string
MaxItems int32
}
type ListRolesOutput struct {
Roles []types.Role
IsTruncated bool
Marker string
}
type UpdateAssumeRolePolicyInput struct {
RoleName string
PolicyDocument string
}
type PutRolePolicyInput struct {
RoleName string
PolicyName string
PolicyDocument string
}
type ListRolePoliciesInput struct {
RoleName string
Marker string
MaxItems int32
}
type ListRolePoliciesOutput struct {
PolicyNames []string
IsTruncated bool
Marker string
}
type 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.
func mergeTags(existing, incoming []types.Tag) ([]types.Tag, error) {
merged := slices.Clone(existing)
for _, tag := range incoming {
if idx := indexOfTagKey(merged, tag.Key); idx >= 0 {
merged[idx] = tag
continue
}
if len(merged) >= MaxTagsPerUser {
return nil, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded)
}
merged = append(merged, tag)
}
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.
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 {
return strings.EqualFold(key, tag.Key)
})
})
}
func indexOfTagKey(tags []types.Tag, key string) int {
return slices.IndexFunc(tags, func(tag types.Tag) bool {
return strings.EqualFold(tag.Key, key)
})
}
// paginateTags sorts tags by key and applies input's Marker/MaxItems window.
// AWS's own ListUserTags returns 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 {
sorted := slices.Clone(tags)
slices.SortFunc(sorted, func(a, b types.Tag) int {
return strings.Compare(a.Key, b.Key)
})
if input.Marker != "" {
start := len(sorted)
if idx := indexOfTagKey(sorted, input.Marker); idx >= 0 {
start = idx + 1
}
sorted = sorted[start:]
}
limit := len(sorted)
if input.MaxItems > 0 && int(input.MaxItems) < limit {
limit = int(input.MaxItems)
}
out := &ListUserTagsOutput{Tags: sorted[:limit]}
if limit < len(sorted) {
out.IsTruncated = true
out.Marker = out.Tags[limit-1].Key
}
return out
}
func unwrapAPIError(err error) error {
var apiErr iamerr.APIError
if errors.As(err, &apiErr) {
return apiErr
}
return err
}
+61
View File
@@ -347,6 +347,67 @@ func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*t
return cloneUser(updated), nil
}
func (s *InternalStore) TagUser(_ context.Context, userName string, tags []types.Tag) error {
return s.updateUserTags(userName, func(user *types.User) error {
merged, err := mergeTags(user.Tags, tags)
if err != nil {
return err
}
user.Tags = merged
return nil
})
}
func (s *InternalStore) UntagUser(_ context.Context, userName string, tagKeys []string) error {
return s.updateUserTags(userName, func(user *types.User) error {
user.Tags = removeTags(user.Tags, tagKeys)
return nil
})
}
// updateUserTags applies mutate to userName's stored record and writes it
// back under the store lock.
func (s *InternalStore) updateUserTags(userName string, mutate func(*types.User) error) error {
s.Lock()
defer s.Unlock()
err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := s.engine.ParseIAM(data)
if err != nil {
return nil, err
}
canonical, user, ok := lookupUser(conf, userName)
if !ok {
return nil, iamerr.NoSuchEntityUser(userName)
}
if err := mutate(&user); err != nil {
return nil, err
}
conf.Users[canonical] = user
return json.Marshal(conf)
})
return unwrapAPIError(err)
}
func (s *InternalStore) ListUserTags(_ context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error) {
s.RLock()
defer s.RUnlock()
conf, err := s.engine.GetIAM()
if err != nil {
return nil, err
}
_, user, ok := lookupUser(conf, input.UserName)
if !ok {
return nil, iamerr.NoSuchEntityUser(input.UserName)
}
return paginateTags(user.Tags, input), nil
}
func (s *InternalStore) CreateAccessKey(_ context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) {
s.Lock()
defer s.Unlock()
+4 -163
View File
@@ -16,167 +16,13 @@ package storage
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/iamapi/types"
)
// MaxAccessKeysPerUser is the maximum number of access keys a single IAM
// user may hold at once, matching the AWS IAM quota.
const MaxAccessKeysPerUser = 2
// MaxInlinePolicyBytesPerUser is the maximum aggregate size, in bytes, of
// all of a single IAM user's inline policy documents combined
const MaxInlinePolicyBytesPerUser = 2048
// MaxInlinePolicyBytesPerRole is the maximum aggregate size, in bytes, of
// all of a single IAM role's inline policy documents combined
const MaxInlinePolicyBytesPerRole = 10240
// MaxClientIDsPerOIDCProvider is the maximum number of client IDs a single
// OIDC provider may hold at once
const MaxClientIDsPerOIDCProvider = 100
// MaxOIDCProvidersPerAccount is the maximum number of OIDC providers a
// single account may hold
const MaxOIDCProvidersPerAccount = 100
// MaxActiveSessionsPerRole bounds how many currently-unexpired
// AssumeRoleWithWebIdentity sessions a single role may have at once.
// AWS manages and rate-limits STS as a hosted service with no
// customer-visible equivalent quota to match for fidelity; this exists
// purely as local resource protection, since without it a single valid
// federated token can be replayed indefinitely to grow the session
// store — every InternalStore rewrite, or Vault KV path/metadata entry —
// without bound. Chosen generously enough to not constrain any legitimate
// workload's concurrent session count.
//
// A var, not a const, so tests can temporarily lower it rather than paying
// the cost of actually creating 1000 sessions to exercise the cap.
var MaxActiveSessionsPerRole = 1000
var (
ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists")
ErrAccessKeyIDAlreadyExists = errors.New("iamapi: access key id already exists")
ErrRoleIDAlreadyExists = errors.New("iamapi: role id already exists")
// ErrSessionNotFound is returned by GetSession when accessKeyID names no
// session, or names one whose Expiration has already passed.
ErrSessionNotFound = errors.New("iamapi: session not found")
)
type ListUsersInput struct {
PathPrefix string
Marker string
MaxItems int32
}
type ListUsersOutput struct {
Users []types.User
IsTruncated bool
Marker string
}
type UpdateUserInput struct {
UserName string
NewPath string
NewUserName string
NewArn string
}
type CreateAccessKeyInput struct {
UserName string
AccessKeyID string
SecretAccessKey string
Status string
CreateDate time.Time
}
type UpdateAccessKeyInput struct {
UserName string
AccessKeyID string
Status string
}
type ListAccessKeysInput struct {
UserName string
Marker string
MaxItems int32
}
type ListAccessKeysOutput struct {
AccessKeys []types.AccessKeyMetadata
IsTruncated bool
Marker string
}
type GetAccessKeyLastUsedOutput struct {
UserName string
LastUsedDate time.Time
ServiceName string
Region string
}
type PutUserPolicyInput struct {
UserName string
PolicyName string
PolicyDocument string
}
type ListUserPoliciesInput struct {
UserName string
Marker string
MaxItems int32
}
type ListUserPoliciesOutput struct {
PolicyNames []string
IsTruncated bool
Marker string
}
type ListRolesInput struct {
PathPrefix string
Marker string
MaxItems int32
}
type ListRolesOutput struct {
Roles []types.Role
IsTruncated bool
Marker string
}
type UpdateAssumeRolePolicyInput struct {
RoleName string
PolicyDocument string
}
type PutRolePolicyInput struct {
RoleName string
PolicyName string
PolicyDocument string
}
type ListRolePoliciesInput struct {
RoleName string
Marker string
MaxItems int32
}
type ListRolePoliciesOutput struct {
PolicyNames []string
IsTruncated bool
Marker string
}
type ListOIDCProvidersOutput struct {
Providers []types.OpenIDConnectProviderListEntry
}
// Storer is the IAM API storage backend contract.
type Storer interface {
CreateUser(ctx context.Context, user types.User) (*types.User, error)
@@ -186,6 +32,10 @@ type Storer interface {
ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error)
UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error)
TagUser(ctx context.Context, userName string, tags []types.Tag) error
UntagUser(ctx context.Context, userName string, tagKeys []string) error
ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error)
CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error)
UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error
DeleteAccessKey(ctx context.Context, username, accessKeyID string) error
@@ -227,15 +77,6 @@ type Storer interface {
GetSession(ctx context.Context, accessKeyID string) (*types.Session, error)
}
func unwrapAPIError(err error) error {
var apiErr iamerr.APIError
if errors.As(err, &apiErr) {
return apiErr
}
return err
}
type Config struct {
Dir string
Vault VaultConfig
+29
View File
@@ -643,6 +643,35 @@ func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate fu
return nil, iamerr.ConcurrentModification()
}
func (s *VaultStore) TagUser(ctx context.Context, userName string, tags []types.Tag) error {
_, err := s.withUserCAS(ctx, userName, func(user *types.User) error {
merged, err := mergeTags(user.Tags, tags)
if err != nil {
return err
}
user.Tags = merged
return nil
})
return err
}
func (s *VaultStore) UntagUser(ctx context.Context, userName string, tagKeys []string) error {
_, err := s.withUserCAS(ctx, userName, func(user *types.User) error {
user.Tags = removeTags(user.Tags, tagKeys)
return nil
})
return err
}
func (s *VaultStore) ListUserTags(ctx context.Context, input ListUserTagsInput) (*ListUserTagsOutput, error) {
user, err := s.GetUser(ctx, input.UserName)
if err != nil {
return nil, err
}
return paginateTags(user.Tags, input), nil
}
func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) {
var created types.AccessKey
if _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error {
+41
View File
@@ -113,3 +113,44 @@ type Tag struct {
Key string
Value string
}
// Tags is the XML wrapper for a tag list, rendering as
// <Tags><member>…</member></Tags> — and, when empty, as <Tags/> rather
// than being omitted, matching AWS's response for an untagged user.
type Tags struct {
Members []Tag `xml:"member"`
}
type TagUserResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagUserResponse"`
ResponseMetadata ResponseMetadata
}
func (r *TagUserResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type UntagUserResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagUserResponse"`
ResponseMetadata ResponseMetadata
}
func (r *UntagUserResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type ListUserTagsResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserTagsResponse"`
Result ListUserTagsResult `xml:"ListUserTagsResult"`
ResponseMetadata ResponseMetadata
}
func (r *ListUserTagsResponse) SetRequestID(requestID string) {
r.ResponseMetadata.RequestID = requestID
}
type ListUserTagsResult struct {
Tags Tags
IsTruncated bool
Marker string `xml:",omitempty"`
}
+84
View File
@@ -1192,6 +1192,51 @@ func TestIAMUpdateUser(ts *TestState) {
ts.Run(IAMUpdateUser_success)
}
func TestIAMTagUser(ts *TestState) {
ts.Run(IAMTagUser_missing_user_name)
ts.Run(IAMTagUser_invalid_user_name)
ts.Run(IAMTagUser_user_name_too_long)
ts.Run(IAMTagUser_missing_tags)
ts.Run(IAMTagUser_missing_tag_key)
ts.Run(IAMTagUser_missing_tag_value)
ts.Run(IAMTagUser_empty_tag_key)
ts.Run(IAMTagUser_tag_key_too_long)
ts.Run(IAMTagUser_invalid_tag_key)
ts.Run(IAMTagUser_tag_value_too_long)
ts.Run(IAMTagUser_invalid_tag_value)
ts.Run(IAMTagUser_duplicate_tag_keys)
ts.Run(IAMTagUser_too_many_tags)
ts.Run(IAMTagUser_non_existing_user)
ts.Run(IAMTagUser_tag_limit_exceeded)
ts.Run(IAMTagUser_success)
ts.Run(IAMTagUser_overwrites_existing_tag)
}
func TestIAMUntagUser(ts *TestState) {
ts.Run(IAMUntagUser_missing_user_name)
ts.Run(IAMUntagUser_invalid_user_name)
ts.Run(IAMUntagUser_user_name_too_long)
ts.Run(IAMUntagUser_missing_tag_keys)
ts.Run(IAMUntagUser_invalid_tag_key)
ts.Run(IAMUntagUser_too_many_tag_keys)
ts.Run(IAMUntagUser_non_existing_user)
ts.Run(IAMUntagUser_success)
ts.Run(IAMUntagUser_removal_is_idempotent)
ts.Run(IAMUntagUser_case_insensitive_key)
}
func TestIAMListUserTags(ts *TestState) {
ts.Run(IAMListUserTags_missing_user_name)
ts.Run(IAMListUserTags_invalid_user_name)
ts.Run(IAMListUserTags_user_name_too_long)
ts.Run(IAMListUserTags_invalid_max_items)
ts.Run(IAMListUserTags_invalid_max_items_format)
ts.Run(IAMListUserTags_non_existing_user)
ts.Run(IAMListUserTags_empty_result)
ts.Run(IAMListUserTags_success)
ts.Run(IAMListUserTags_pagination)
}
func TestIAMCreateAccessKey(ts *TestState) {
ts.Run(IAMCreateAccessKey_missing_user_name)
ts.Run(IAMCreateAccessKey_invalid_user_name)
@@ -1633,6 +1678,9 @@ func TestIAM(ts *TestState) {
TestIAMListUsers(ts)
TestIAMDeleteUser(ts)
TestIAMUpdateUser(ts)
TestIAMTagUser(ts)
TestIAMUntagUser(ts)
TestIAMListUserTags(ts)
TestIAMCreateAccessKey(ts)
TestIAMUpdateAccessKey(ts)
TestIAMDeleteAccessKey(ts)
@@ -2123,6 +2171,42 @@ func GetIntTests() IntTests {
"IAMUpdateUser_long_new_path": IAMUpdateUser_long_new_path,
"IAMUpdateUser_new_user_name_already_exists": IAMUpdateUser_new_user_name_already_exists,
"IAMUpdateUser_success": IAMUpdateUser_success,
"IAMTagUser_missing_user_name": IAMTagUser_missing_user_name,
"IAMTagUser_invalid_user_name": IAMTagUser_invalid_user_name,
"IAMTagUser_user_name_too_long": IAMTagUser_user_name_too_long,
"IAMTagUser_missing_tags": IAMTagUser_missing_tags,
"IAMTagUser_missing_tag_key": IAMTagUser_missing_tag_key,
"IAMTagUser_missing_tag_value": IAMTagUser_missing_tag_value,
"IAMTagUser_empty_tag_key": IAMTagUser_empty_tag_key,
"IAMTagUser_tag_key_too_long": IAMTagUser_tag_key_too_long,
"IAMTagUser_invalid_tag_key": IAMTagUser_invalid_tag_key,
"IAMTagUser_tag_value_too_long": IAMTagUser_tag_value_too_long,
"IAMTagUser_invalid_tag_value": IAMTagUser_invalid_tag_value,
"IAMTagUser_duplicate_tag_keys": IAMTagUser_duplicate_tag_keys,
"IAMTagUser_too_many_tags": IAMTagUser_too_many_tags,
"IAMTagUser_non_existing_user": IAMTagUser_non_existing_user,
"IAMTagUser_tag_limit_exceeded": IAMTagUser_tag_limit_exceeded,
"IAMTagUser_success": IAMTagUser_success,
"IAMTagUser_overwrites_existing_tag": IAMTagUser_overwrites_existing_tag,
"IAMUntagUser_missing_user_name": IAMUntagUser_missing_user_name,
"IAMUntagUser_invalid_user_name": IAMUntagUser_invalid_user_name,
"IAMUntagUser_user_name_too_long": IAMUntagUser_user_name_too_long,
"IAMUntagUser_missing_tag_keys": IAMUntagUser_missing_tag_keys,
"IAMUntagUser_invalid_tag_key": IAMUntagUser_invalid_tag_key,
"IAMUntagUser_too_many_tag_keys": IAMUntagUser_too_many_tag_keys,
"IAMUntagUser_non_existing_user": IAMUntagUser_non_existing_user,
"IAMUntagUser_success": IAMUntagUser_success,
"IAMUntagUser_removal_is_idempotent": IAMUntagUser_removal_is_idempotent,
"IAMUntagUser_case_insensitive_key": IAMUntagUser_case_insensitive_key,
"IAMListUserTags_missing_user_name": IAMListUserTags_missing_user_name,
"IAMListUserTags_invalid_user_name": IAMListUserTags_invalid_user_name,
"IAMListUserTags_user_name_too_long": IAMListUserTags_user_name_too_long,
"IAMListUserTags_invalid_max_items": IAMListUserTags_invalid_max_items,
"IAMListUserTags_invalid_max_items_format": IAMListUserTags_invalid_max_items_format,
"IAMListUserTags_non_existing_user": IAMListUserTags_non_existing_user,
"IAMListUserTags_empty_result": IAMListUserTags_empty_result,
"IAMListUserTags_success": IAMListUserTags_success,
"IAMListUserTags_pagination": IAMListUserTags_pagination,
"IAMCreateAccessKey_missing_user_name": IAMCreateAccessKey_missing_user_name,
"IAMCreateAccessKey_invalid_user_name": IAMCreateAccessKey_invalid_user_name,
"IAMCreateAccessKey_long_user_name": IAMCreateAccessKey_long_user_name,
+6 -4
View File
@@ -63,12 +63,15 @@ func IAMListAccessKeys_invalid_max_items(s *S3Conf) error {
testName := "IAMListAccessKeys_invalid_max_items"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := "non-existing-" + genRandString(16)
for _, maxItems := range []int32{-1, 0, 1001} {
for maxItems, expected := range map[int32]iamerr.Error{
-1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
} {
_, err := listIAMAccessKeys(client, &iam.ListAccessKeysInput{
UserName: &userName,
MaxItems: aws.Int32(maxItems),
})
expected := iamerr.InvalidMaxItems(fmt.Sprint(maxItems))
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
}
@@ -94,8 +97,7 @@ func IAMListAccessKeys_invalid_max_items_format(s *S3Conf) error {
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000")
return checkIAMAuthRequest(s, req, expected)
return checkIAMAuthRequest(s, req, iamerr.MalformedInput())
})
}
+1 -1
View File
@@ -70,7 +70,7 @@ func IAMListRolePolicies_invalid_max_items(s *S3Conf) error {
_, err := listIAMRolePolicies(client, &iam.ListRolePoliciesInput{RoleName: &roleName, MaxItems: aws.Int32(1001)})
return err
}(),
iamerr.InvalidMaxItems("1001"),
iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
)
deleteErr := deleteIAMRole(client, roleName)
+6 -4
View File
@@ -58,9 +58,12 @@ func IAMListRoles_long_path_prefix(s *S3Conf) error {
func IAMListRoles_invalid_max_items(s *S3Conf) error {
testName := "IAMListRoles_invalid_max_items"
return iamActionHandler(s, testName, func(client *iam.Client) error {
for _, maxItems := range []int32{-1, 0, 1001} {
for maxItems, expected := range map[int32]iamerr.Error{
-1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
} {
_, err := listIAMRoles(client, &iam.ListRolesInput{MaxItems: aws.Int32(maxItems)})
expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems))
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
}
@@ -85,8 +88,7 @@ func IAMListRoles_invalid_max_items_format(s *S3Conf) error {
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000")
return checkIAMAuthRequest(s, req, expected)
return checkIAMAuthRequest(s, req, iamerr.MalformedInput())
})
}
+1 -1
View File
@@ -67,7 +67,7 @@ func IAMListUserPolicies_invalid_max_items(s *S3Conf) error {
_, err := listIAMUserPolicies(client, &iam.ListUserPoliciesInput{UserName: &userName, MaxItems: aws.Int32(1001)})
return err
}(),
iamerr.InvalidMaxItems("1001"),
iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
)
deleteErr := deleteIAMUser(client, userName)
+261
View File
@@ -0,0 +1,261 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/versity/versitygw/iamapi/iamerr"
)
func IAMListUserTags_missing_user_name(s *S3Conf) error {
testName := "IAMListUserTags_missing_user_name"
body := []byte(url.Values{
"Action": {"ListUserTags"},
"Version": {"2010-05-08"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
})
}
func IAMListUserTags_invalid_user_name(s *S3Conf) error {
testName := "IAMListUserTags_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := listIAMUserTags(client, &iam.ListUserTagsInput{
UserName: aws.String("invalid user name"),
})
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMListUserTags_user_name_too_long(s *S3Conf) error {
testName := "IAMListUserTags_user_name_too_long"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := listIAMUserTags(client, &iam.ListUserTagsInput{
UserName: aws.String(strings.Repeat("a", 129)),
})
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128))
})
}
func IAMListUserTags_invalid_max_items(s *S3Conf) error {
testName := "IAMListUserTags_invalid_max_items"
return iamActionHandler(s, testName, func(client *iam.Client) error {
for maxItems, expected := range map[int32]iamerr.Error{
-1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
} {
_, err := listIAMUserTags(client, &iam.ListUserTagsInput{
UserName: aws.String("validusername"),
MaxItems: aws.Int32(maxItems),
})
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
}
}
return nil
})
}
func IAMListUserTags_invalid_max_items_format(s *S3Conf) error {
testName := "IAMListUserTags_invalid_max_items_format"
body := []byte(url.Values{
"Action": {"ListUserTags"},
"Version": {"2010-05-08"},
"UserName": {"validusername"},
"MaxItems": {"not-a-number"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MalformedInput())
})
}
func IAMListUserTags_non_existing_user(s *S3Conf) error {
testName := "IAMListUserTags_non_existing_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := "non-existing-" + genRandString(16)
_, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
func IAMListUserTags_empty_result(s *S3Conf) error {
testName := "IAMListUserTags_empty_result"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName})
if err != nil {
return err
}
if len(out.Tags) != 0 {
return fmt.Errorf("expected no tags, instead got %v", iamTagMap(out.Tags))
}
if out.IsTruncated {
return fmt.Errorf("expected IsTruncated to be false")
}
return nil
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func IAMListUserTags_success(s *S3Conf) error {
testName := "IAMListUserTags_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"created": "at-create-time"}),
}); err != nil {
return err
}
checkErr := func() error {
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "prod", "team": "storage"}),
}); err != nil {
return err
}
out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName})
if err != nil {
return err
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected ListUserTags response request id")
}
if out.IsTruncated {
return fmt.Errorf("expected IsTruncated to be false")
}
// Tags supplied at creation and tags added afterwards are the
// same set: TagUser merges into whatever CreateUser stored.
return compareIAMTags(out.Tags, map[string]string{
"created": "at-create-time",
"env": "prod",
"team": "storage",
})
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func IAMListUserTags_pagination(s *S3Conf) error {
testName := "IAMListUserTags_pagination"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
want := []string{"alpha", "beta", "gamma"}
tags := map[string]string{}
for _, key := range want {
tags[key] = key + "-value"
}
if _, err := tagIAMUser(client, &iam.TagUserInput{UserName: &userName, Tags: iamTagList(tags)}); err != nil {
return err
}
input := iam.ListUserTagsInput{UserName: &userName, MaxItems: aws.Int32(1)}
var pages []*iam.ListUserTagsOutput
for {
out, err := listIAMUserTags(client, &input)
if err != nil {
return err
}
pages = append(pages, out)
if !out.IsTruncated {
break
}
input.Marker = out.Marker
}
if len(pages) != len(want) {
return fmt.Errorf("expected %d pages, instead got %d", len(want), len(pages))
}
var got []string
for i, page := range pages {
if len(page.Tags) != 1 {
return fmt.Errorf("expected page %d to contain 1 tag, instead got %d", i+1, len(page.Tags))
}
if page.IsTruncated != (i < len(pages)-1) {
return fmt.Errorf("unexpected IsTruncated value on page %d", i+1)
}
got = append(got, aws.ToString(page.Tags[0].Key))
}
slices.Sort(got)
if !slices.Equal(got, want) {
return fmt.Errorf("expected tag keys %v, instead got %v", want, got)
}
return nil
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func listIAMUserTags(client *iam.Client, input *iam.ListUserTagsInput) (*iam.ListUserTagsOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.ListUserTags(ctx, input)
}
+6 -4
View File
@@ -57,9 +57,12 @@ func IAMListUsers_long_path_prefix(s *S3Conf) error {
func IAMListUsers_invalid_max_items(s *S3Conf) error {
testName := "IAMListUsers_invalid_max_items"
return iamActionHandler(s, testName, func(client *iam.Client) error {
for _, maxItems := range []int32{-1, 0, 1001} {
for maxItems, expected := range map[int32]iamerr.Error{
-1: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
0: iamerr.GetAPIError(iamerr.ErrMaxItemsTooLow),
1001: iamerr.GetAPIError(iamerr.ErrMaxItemsTooHigh),
} {
_, err := listIAMUsers(client, &iam.ListUsersInput{MaxItems: aws.Int32(maxItems)})
expected := iamerr.ValidationError(fmt.Sprintf("1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000", maxItems))
if checkErr := checkIAMApiErr(err, expected); checkErr != nil {
return fmt.Errorf("MaxItems %d: %w", maxItems, checkErr)
}
@@ -84,8 +87,7 @@ func IAMListUsers_invalid_max_items_format(s *S3Conf) error {
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
expected := iamerr.ValidationError("1 validation error detected: Value 'not-a-number' at 'maxItems' failed to satisfy constraint: Member must have value between 1 and 1000")
return checkIAMAuthRequest(s, req, expected)
return checkIAMAuthRequest(s, req, iamerr.MalformedInput())
})
}
+427
View File
@@ -0,0 +1,427 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/versity/versitygw/iamapi/iamerr"
"github.com/versity/versitygw/iamapi/storage"
)
func IAMTagUser_missing_user_name(s *S3Conf) error {
testName := "IAMTagUser_missing_user_name"
body := []byte(url.Values{
"Action": {"TagUser"},
"Version": {"2010-05-08"},
"Tags.member.1.Key": {"env"},
"Tags.member.1.Value": {"prod"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
})
}
func IAMTagUser_invalid_user_name(s *S3Conf) error {
testName := "IAMTagUser_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("invalid user name"),
Tags: iamTagList(map[string]string{"env": "prod"}),
})
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMTagUser_user_name_too_long(s *S3Conf) error {
testName := "IAMTagUser_user_name_too_long"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String(strings.Repeat("a", 129)),
Tags: iamTagList(map[string]string{"env": "prod"}),
})
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128))
})
}
func IAMTagUser_missing_tags(s *S3Conf) error {
testName := "IAMTagUser_missing_tags"
body := []byte(url.Values{
"Action": {"TagUser"},
"Version": {"2010-05-08"},
"UserName": {"validusername"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingValue("tags"))
})
}
func IAMTagUser_missing_tag_key(s *S3Conf) error {
testName := "IAMTagUser_missing_tag_key"
body := []byte(url.Values{
"Action": {"TagUser"},
"Version": {"2010-05-08"},
"UserName": {"validusername"},
"Tags.member.1.Value": {"prod"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingTagKey(1))
})
}
func IAMTagUser_missing_tag_value(s *S3Conf) error {
testName := "IAMTagUser_missing_tag_value"
body := []byte(url.Values{
"Action": {"TagUser"},
"Version": {"2010-05-08"},
"UserName": {"validusername"},
"Tags.member.1.Key": {"env"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingTagValue(1))
})
}
func IAMTagUser_empty_tag_key(s *S3Conf) error {
testName := "IAMTagUser_empty_tag_key"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: []iamtypes.Tag{{Key: aws.String(""), Value: aws.String("prod")}},
})
return checkIAMApiErr(err, iamerr.TagKeyTooShort(1))
})
}
func IAMTagUser_tag_key_too_long(s *S3Conf) error {
testName := "IAMTagUser_tag_key_too_long"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: iamTagList(map[string]string{strings.Repeat("k", 129): "prod"}),
})
return checkIAMApiErr(err, iamerr.TagKeyTooLong(1))
})
}
func IAMTagUser_invalid_tag_key(s *S3Conf) error {
testName := "IAMTagUser_invalid_tag_key"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: iamTagList(map[string]string{"invalid*key": "prod"}),
})
return checkIAMApiErr(err, iamerr.InvalidTagKey(1))
})
}
func IAMTagUser_tag_value_too_long(s *S3Conf) error {
testName := "IAMTagUser_tag_value_too_long"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: iamTagList(map[string]string{"env": strings.Repeat("v", 257)}),
})
return checkIAMApiErr(err, iamerr.TagValueTooLong(1))
})
}
func IAMTagUser_invalid_tag_value(s *S3Conf) error {
testName := "IAMTagUser_invalid_tag_value"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: iamTagList(map[string]string{"env": "invalid*value"}),
})
return checkIAMApiErr(err, iamerr.InvalidTagValue(1))
})
}
// IAMTagUser_duplicate_tag_keys covers both an exact repeat and a
// differently-cased repeat: IAM compares tag keys case-insensitively, so
// both are the same key twice in one request.
func IAMTagUser_duplicate_tag_keys(s *S3Conf) error {
testName := "IAMTagUser_duplicate_tag_keys"
return iamActionHandler(s, testName, func(client *iam.Client) error {
for _, second := range []string{"env", "ENV"} {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: []iamtypes.Tag{
{Key: aws.String("env"), Value: aws.String("prod")},
{Key: aws.String(second), Value: aws.String("staging")},
},
})
if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrDuplicateTagKeys)); checkErr != nil {
return fmt.Errorf("duplicate key %q: %w", second, checkErr)
}
}
return nil
})
}
func IAMTagUser_too_many_tags(s *S3Conf) error {
testName := "IAMTagUser_too_many_tags"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: aws.String("validusername"),
Tags: numberedIAMTags(1, maxIAMTagMembersPerRequest+1),
})
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTags))
})
}
func IAMTagUser_non_existing_user(s *S3Conf) error {
testName := "IAMTagUser_non_existing_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := "non-existing-" + genRandString(16)
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "prod"}),
})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
func IAMTagUser_tag_limit_exceeded(s *S3Conf) error {
testName := "IAMTagUser_tag_limit_exceeded"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: numberedIAMTags(1, storage.MaxTagsPerUser),
}); err != nil {
return err
}
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"key1": "replaced"}),
}); err != nil {
return fmt.Errorf("replacing a tag at the quota: %w", err)
}
_, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"overflow": "x"}),
})
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTagLimitExceeded))
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func IAMTagUser_success(s *S3Conf) error {
testName := "IAMTagUser_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
// An empty tag value is legal; only the key has a minimum length.
want := map[string]string{"env": "prod", "team": "storage", "empty": ""}
out, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(want),
})
if err != nil {
return err
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected TagUser response request id")
}
if err := checkIAMUserTags(client, userName, want); err != nil {
return err
}
// GetUser reports the same tags the tag actions maintain.
user, err := getIAMUser(client, &iam.GetUserInput{UserName: &userName})
if err != nil {
return err
}
return compareIAMTags(user.User.Tags, want)
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
// IAMTagUser_overwrites_existing_tag covers re-tagging a key that is
// already present: the value is replaced rather than added alongside, and a
// differently-cased key is the same tag — the newly supplied casing wins.
func IAMTagUser_overwrites_existing_tag(s *S3Conf) error {
testName := "IAMTagUser_overwrites_existing_tag"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "prod"}),
}); err != nil {
return err
}
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "staging"}),
}); err != nil {
return err
}
if err := checkIAMUserTags(client, userName, map[string]string{"env": "staging"}); err != nil {
return err
}
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"ENV": "qa"}),
}); err != nil {
return err
}
return checkIAMUserTags(client, userName, map[string]string{"ENV": "qa"})
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
// maxIAMTagMembersPerRequest mirrors the per-request Tags/TagKeys member
// cap the service enforces
const maxIAMTagMembersPerRequest = 50
func tagIAMUser(client *iam.Client, input *iam.TagUserInput) (*iam.TagUserOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.TagUser(ctx, input)
}
func iamTagList(tags map[string]string) []iamtypes.Tag {
list := make([]iamtypes.Tag, 0, len(tags))
for key, value := range tags {
list = append(list, iamtypes.Tag{Key: aws.String(key), Value: aws.String(value)})
}
return list
}
// numberedIAMTags builds count tags named key<N>/value<N> starting at
// first, for exercising the per-request and per-user tag quotas.
func numberedIAMTags(first, count int) []iamtypes.Tag {
list := make([]iamtypes.Tag, 0, count)
for i := first; i < first+count; i++ {
list = append(list, iamtypes.Tag{
Key: aws.String(fmt.Sprintf("key%d", i)),
Value: aws.String(fmt.Sprintf("value%d", i)),
})
}
return list
}
// checkIAMUserTags asserts ListUserTags reports exactly want for userName.
func checkIAMUserTags(client *iam.Client, userName string, want map[string]string) error {
out, err := listIAMUserTags(client, &iam.ListUserTagsInput{UserName: &userName})
if err != nil {
return err
}
if out.IsTruncated {
return fmt.Errorf("expected IsTruncated to be false")
}
return compareIAMTags(out.Tags, want)
}
func compareIAMTags(got []iamtypes.Tag, want map[string]string) error {
if len(got) != len(want) {
return fmt.Errorf("expected %d tags, instead got %d: %v", len(want), len(got), iamTagMap(got))
}
for key, value := range want {
found, ok := iamTagMap(got)[key]
if !ok {
return fmt.Errorf("expected tag %q to be present, instead got %v", key, iamTagMap(got))
}
if found != value {
return fmt.Errorf("expected tag %q to be %q, instead got %q", key, value, found)
}
}
return nil
}
func iamTagMap(tags []iamtypes.Tag) map[string]string {
out := make(map[string]string, len(tags))
for _, tag := range tags {
out[aws.ToString(tag.Key)] = aws.ToString(tag.Value)
}
return out
}
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/versity/versitygw/iamapi/iamerr"
)
func IAMUntagUser_missing_user_name(s *S3Conf) error {
testName := "IAMUntagUser_missing_user_name"
body := []byte(url.Values{
"Action": {"UntagUser"},
"Version": {"2010-05-08"},
"TagKeys.member.1": {"env"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingValue("userName"))
})
}
func IAMUntagUser_invalid_user_name(s *S3Conf) error {
testName := "IAMUntagUser_invalid_user_name"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: aws.String("invalid user name"),
TagKeys: []string{"env"},
})
return checkIAMApiErr(err, iamerr.InvalidUserName("userName"))
})
}
func IAMUntagUser_user_name_too_long(s *S3Conf) error {
testName := "IAMUntagUser_user_name_too_long"
return iamActionHandler(s, testName, func(client *iam.Client) error {
_, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: aws.String(strings.Repeat("a", 129)),
TagKeys: []string{"env"},
})
return checkIAMApiErr(err, iamerr.UserNameTooLong("userName", 128))
})
}
func IAMUntagUser_missing_tag_keys(s *S3Conf) error {
testName := "IAMUntagUser_missing_tag_keys"
body := []byte(url.Values{
"Action": {"UntagUser"},
"Version": {"2010-05-08"},
"UserName": {"validusername"},
}.Encode())
return authHandler(s, &authConfig{
testName: testName,
method: http.MethodPost,
service: "iam",
region: iamAuthRegion,
body: body,
date: time.Now().UTC(),
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
}, func(req *http.Request) error {
return checkIAMAuthRequest(s, req, iamerr.MissingValue("tagKeys"))
})
}
// IAMUntagUser_invalid_tag_key covers every malformed TagKeys member the
// same way IAM does: whichever constraint fails — empty, over-long, or
// outside the allowed charset — the response is one generic error naming
// the whole constraint set, not the individual constraint that tripped.
func IAMUntagUser_invalid_tag_key(s *S3Conf) error {
testName := "IAMUntagUser_invalid_tag_key"
return iamActionHandler(s, testName, func(client *iam.Client) error {
for _, tagKey := range []string{"", strings.Repeat("k", 129), "invalid*key"} {
_, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: aws.String("validusername"),
TagKeys: []string{tagKey},
})
if checkErr := checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidTagKeys)); checkErr != nil {
return fmt.Errorf("tag key %q: %w", tagKey, checkErr)
}
}
return nil
})
}
func IAMUntagUser_too_many_tag_keys(s *S3Conf) error {
testName := "IAMUntagUser_too_many_tag_keys"
return iamActionHandler(s, testName, func(client *iam.Client) error {
tagKeys := make([]string, 0, maxIAMTagMembersPerRequest+1)
for i := range maxIAMTagMembersPerRequest + 1 {
tagKeys = append(tagKeys, fmt.Sprintf("key%d", i+1))
}
_, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: aws.String("validusername"),
TagKeys: tagKeys,
})
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrTooManyTagKeys))
})
}
func IAMUntagUser_non_existing_user(s *S3Conf) error {
testName := "IAMUntagUser_non_existing_user"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := "non-existing-" + genRandString(16)
_, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: &userName,
TagKeys: []string{"env"},
})
return checkIAMApiErr(err, iamerr.NoSuchEntityUser(userName))
})
}
func IAMUntagUser_success(s *S3Conf) error {
testName := "IAMUntagUser_success"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "prod", "team": "storage", "owner": "alice"}),
}); err != nil {
return err
}
out, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: &userName,
TagKeys: []string{"env", "owner"},
})
if err != nil {
return err
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata); !ok || requestID == "" {
return fmt.Errorf("expected UntagUser response request id")
}
return checkIAMUserTags(client, userName, map[string]string{"team": "storage"})
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
// IAMUntagUser_removal_is_idempotent covers the two ways a request can name
// a key that removes nothing: a key the user never carried, and the same
// key twice in one request. Neither is an error — unlike TagUser, which
// rejects a repeated key outright.
func IAMUntagUser_removal_is_idempotent(s *S3Conf) error {
testName := "IAMUntagUser_removal_is_idempotent"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "prod"}),
}); err != nil {
return err
}
if _, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: &userName,
TagKeys: []string{"never-existed"},
}); err != nil {
return fmt.Errorf("removing a key the user does not carry: %w", err)
}
if err := checkIAMUserTags(client, userName, map[string]string{"env": "prod"}); err != nil {
return err
}
if _, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: &userName,
TagKeys: []string{"env", "env"},
}); err != nil {
return fmt.Errorf("removing the same key twice: %w", err)
}
return checkIAMUserTags(client, userName, map[string]string{})
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
// IAMUntagUser_case_insensitive_key covers removal by a differently-cased
// key: IAM compares tag keys case-insensitively, so the tag is removed even
// though the supplied key does not match the stored casing.
func IAMUntagUser_case_insensitive_key(s *S3Conf) error {
testName := "IAMUntagUser_case_insensitive_key"
return iamActionHandler(s, testName, func(client *iam.Client) error {
userName := newIAMUserName()
if _, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName}); err != nil {
return err
}
checkErr := func() error {
if _, err := tagIAMUser(client, &iam.TagUserInput{
UserName: &userName,
Tags: iamTagList(map[string]string{"env": "prod"}),
}); err != nil {
return err
}
if _, err := untagIAMUser(client, &iam.UntagUserInput{
UserName: &userName,
TagKeys: []string{"EnV"},
}); err != nil {
return err
}
return checkIAMUserTags(client, userName, map[string]string{})
}()
deleteErr := deleteIAMUser(client, userName)
if checkErr != nil {
return checkErr
}
return deleteErr
})
}
func untagIAMUser(client *iam.Client, input *iam.UntagUserInput) (*iam.UntagUserOutput, error) {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
return client.UntagUser(ctx, input)
}
+75 -15
View File
@@ -211,7 +211,7 @@ under the License.
<button type="button" onclick="iamAddTagRow('create-user-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-user-tags" class="space-y-2"></div>
<p class="mt-2 text-xs text-charcoal-300">Tags are set at creation only. This API has no tag-update action, so they are read-only afterwards.</p>
<p class="mt-2 text-xs text-charcoal-300">Optional. Tags can also be added, changed and removed later from the users 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">
@@ -275,13 +275,21 @@ under the License.
<dt class="text-charcoal-300">Created</dt>
<dd id="detail-created" class="mt-1 text-charcoal">-</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-charcoal-300">Tags</dt>
<dd id="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="tag-quota-note" class="text-xs text-charcoal-300 mt-1">Key/value labels, also readable from policy conditions.</p>
</div>
<button id="edit-tags-btn" onclick="openUserTagEditor()" 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="user-tags" class="border border-gray-100 rounded-lg p-4 flex flex-wrap gap-2"></div>
</div>
<!-- Access Keys -->
<div>
<div class="flex items-center justify-between mb-3">
@@ -451,6 +459,7 @@ under the License.
let nextMarker = null;
let currentUser = null; // the user open in the manage modal
let policySizes = {}; // policyName -> byte length, for the aggregate quota
let currentTags = []; // the open user's tags, as [{Key, Value}]
let activeKeyCount = 0;
let userToDelete = null;
// The generated secret lives here and nowhere else: never sessionStorage,
@@ -642,6 +651,7 @@ under the License.
}
}
policySizes = {};
currentTags = [];
activeKeyCount = 0;
document.getElementById('manage-user-title').textContent = userName;
@@ -650,23 +660,73 @@ under the License.
document.getElementById('detail-userid').textContent = currentUser.UserId || '-';
document.getElementById('detail-path').textContent = currentUser.Path || '/';
document.getElementById('detail-created').textContent = iamFormatDate(currentUser.CreateDate);
renderTags(currentUser.Tags);
openModal('manage-user-modal');
loadUserTags();
loadAccessKeys();
loadUserPolicies();
}
function renderTags(tags) {
const el = document.getElementById('detail-tags');
const list = Array.isArray(tags) ? tags : (tags ? [tags] : []);
if (list.length === 0) {
el.innerHTML = '<span class="text-charcoal-300">-</span>';
return;
// ============================================
// Manage: tags
// ============================================
/**
* ListUserTags is its own permission, so this loads the tags rather than
* reusing whatever GetUser happened to return — and a denial disables
* editing in place instead of failing the whole modal.
*/
async function loadUserTags() {
const el = document.getElementById('user-tags');
el.innerHTML = '<span class="text-sm text-charcoal-300">Loading...</span>';
try {
currentTags = [];
let marker = null;
do {
const page = await api.iamListUserTags(currentUser.UserName, { marker: marker || undefined });
currentTags = currentTags.concat(page.tags);
marker = page.isTruncated ? page.marker : null;
} while (marker);
el.innerHTML = iamTagChips(currentTags);
setEditTagsEnabled(true);
updateTagQuotaNote();
} catch (error) {
console.error('Error loading tags:', error);
setEditTagsEnabled(false);
el.innerHTML = iamIsAccessDenied(error)
? '<span class="text-sm text-charcoal-300">You don\u2019t have permission to list this user\u2019s tags</span>'
: `<span class="text-sm text-charcoal-300">Error loading tags: ${escapeHtml(iamShortError(error))}</span>`;
}
el.innerHTML = list.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('');
}
function setEditTagsEnabled(enabled) {
const button = document.getElementById('edit-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 updateTagQuotaNote() {
document.getElementById('tag-quota-note').textContent =
`${currentTags.length} / ${IAM_LIMITS.tagsPerResource} tags. Also readable from policy conditions.`;
}
function openUserTagEditor() {
iamTagEditor.open({
title: 'Edit Tags',
subtitle: `User ${currentUser.UserName}`,
tags: currentTags,
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.iamUntagUser(currentUser.UserName, remove);
if (set.length) await api.iamTagUser(currentUser.UserName, set);
showToast('Tags updated successfully', 'success');
loadUserTags();
}
});
}
async function loadAccessKeys() {
+30
View File
@@ -2493,6 +2493,36 @@ ${tagsXml}
await this.iamRequest('DeleteUser', { UserName: userName });
}
// ---- User tags ----
/**
* Add or replace tags on a user. A key already present is overwritten
* rather than duplicated, so this doubles as the edit path.
*/
async iamTagUser(userName, tags) {
const params = { UserName: userName };
this.flattenTags(params, tags);
await this.iamRequest('TagUser', params);
}
async iamUntagUser(userName, tagKeys) {
const params = { UserName: userName };
this.flattenMemberList(params, 'TagKeys', tagKeys);
await this.iamRequest('UntagUser', params);
}
async iamListUserTags(userName, options = {}) {
const params = { UserName: userName };
if (options.marker) params.Marker = options.marker;
if (options.maxItems) params.MaxItems = options.maxItems;
const result = await this.iamRequest('ListUserTags', params);
return {
tags: iamAsArray(result.Tags),
isTruncated: result.IsTruncated === 'true',
marker: result.Marker || null
};
}
// ---- Access keys ----
/**
+235
View File
@@ -38,6 +38,8 @@ const IAM_LIMITS = {
tagsPerResource: 50,
tagKeyChars: 128,
tagValueChars: 256,
tagKeyPattern: /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u,
tagValuePattern: /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u,
minSessionDuration: 3600,
maxSessionDuration: 43200,
oidcClientIds: 100,
@@ -164,6 +166,56 @@ function iamValidatePath(path) {
return null;
}
/**
* Validate a collected tag list against the same rules the server applies,
* so an obvious mistake is caught before a round trip. Returns an error
* string, or null when the list is acceptable.
*/
function iamValidateTags(tags) {
if (tags.length > IAM_LIMITS.tagsPerResource) {
return `A user can carry ${IAM_LIMITS.tagsPerResource} tags at most.`;
}
const seen = new Set();
for (const tag of tags) {
if (!tag.Key) return 'Every tag needs a key.';
if (tag.Key.length > IAM_LIMITS.tagKeyChars) {
return `Tag key "${tag.Key}" must be ${IAM_LIMITS.tagKeyChars} characters or fewer.`;
}
if (!IAM_LIMITS.tagKeyPattern.test(tag.Key)) {
return `Tag key "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`;
}
if ((tag.Value || '').length > IAM_LIMITS.tagValueChars) {
return `Tag value for "${tag.Key}" must be ${IAM_LIMITS.tagValueChars} characters or fewer.`;
}
if (!IAM_LIMITS.tagValuePattern.test(tag.Value || '')) {
return `Tag value for "${tag.Key}" may contain letters, numbers, spaces and _ . : / = + - @ only.`;
}
// Tag keys are compared case-insensitively, so "env" and "ENV" are the
// same key twice — which the service rejects outright.
const folded = tag.Key.toLowerCase();
if (seen.has(folded)) return `Tag key "${tag.Key}" is listed twice. Keys are case insensitive.`;
seen.add(folded);
}
return null;
}
/**
* Render a tag list as read-only two-tone chips, key alongside value.
*/
function iamTagChips(tags) {
if (!tags.length) return '<span class="text-sm text-charcoal-300">No tags</span>';
return tags.map(tag => {
const value = tag.Value
? `<span class="px-2 py-1 bg-white border-l border-gray-200 text-charcoal-400">${escapeHtml(tag.Value)}</span>`
: '<span class="px-2 py-1 bg-white border-l border-gray-200 text-charcoal-300 italic">empty</span>';
return `<span class="inline-flex items-center overflow-hidden rounded-md border border-gray-200 bg-gray-50 text-xs font-mono">
<span class="px-2 py-1 font-medium text-charcoal">${escapeHtml(tag.Key)}</span>${value}
</span>`;
}).join('');
}
// ============================================
// Repeatable form rows (tags, client IDs, thumbprints)
// ============================================
@@ -639,3 +691,186 @@ const iamPolicyEditor = {
}
}
};
// ============================================
// Tag editor
// ============================================
/**
* 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
* 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.
*/
const iamTagEditor = {
_state: null,
_ensureModal() {
if (document.getElementById('iam-tag-modal')) return;
const wrapper = document.createElement('div');
wrapper.id = 'iam-tag-modal';
wrapper.className = 'modal hidden fixed inset-0 z-50';
wrapper.innerHTML = `<div class="modal-backdrop absolute inset-0" onclick="iamTagEditor.close()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between p-6 border-b border-gray-100 flex-shrink-0">
<div>
<h2 id="iam-tag-title" class="text-xl font-semibold text-charcoal">Tags</h2>
<p id="iam-tag-subtitle" class="text-sm text-charcoal-300 mt-1"></p>
</div>
<button onclick="iamTagEditor.close()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-auto">
<div class="p-6 space-y-5">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<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/&lt;key&gt;</code> for the tagged user and <code class="font-mono">aws:ResourceTag/&lt;key&gt;</code> for the user being acted on. Keys are case insensitive; values may be empty.</p>
</div>
</div>
</div>
<div id="iam-tag-status" class="hidden"></div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-charcoal">Tags</label>
<button type="button" onclick="iamTagEditor.addRow()" 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="iam-tag-rows" class="space-y-2"></div>
<p id="iam-tag-empty" class="hidden py-6 text-center text-sm text-charcoal-300">No tags yet. Use <span class="font-medium">Add Tag</span> to create one.</p>
<p id="iam-tag-counter" class="mt-3 text-xs text-charcoal-300"></p>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100 flex-shrink-0">
<button onclick="iamTagEditor.close()" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="iam-tag-save-btn" onclick="iamTagEditor.save()" class="px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">Save Tags</button>
</div>
</div>
</div>`;
document.body.appendChild(wrapper);
// iamAddTagRow and iamRemoveRow are shared with the create forms and
// report nothing back, so watch the container instead of hooking them.
new MutationObserver(() => this.updateCounter())
.observe(document.getElementById('iam-tag-rows'), { childList: true });
},
/**
* @param {Object} opts
* title modal heading
* subtitle the identity these tags belong to
* tags current tags, as [{Key, Value}]
* onSave async ({ set, remove }) => void, where set holds the tags to
* add or overwrite and remove holds the keys to drop
*/
open(opts) {
this._ensureModal();
this._state = Object.assign({}, opts);
document.getElementById('iam-tag-title').textContent = opts.title || 'Tags';
document.getElementById('iam-tag-subtitle').textContent = opts.subtitle || '';
const rows = document.getElementById('iam-tag-rows');
rows.innerHTML = '';
(opts.tags || []).forEach(tag => iamAddTagRow('iam-tag-rows', tag.Key, tag.Value || ''));
this._setStatus(null);
this.updateCounter();
openModal('iam-tag-modal');
},
close() {
this._state = null;
closeModal('iam-tag-modal');
},
addRow() {
iamAddTagRow('iam-tag-rows');
const rows = document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]');
const last = rows[rows.length - 1];
if (last) last.querySelector('[data-tag-key]').focus();
},
updateCounter() {
const rows = document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]').length;
document.getElementById('iam-tag-empty').classList.toggle('hidden', rows > 0);
const counter = document.getElementById('iam-tag-counter');
const over = rows > IAM_LIMITS.tagsPerResource;
counter.className = 'mt-3 text-xs ' + (over ? 'text-red-600 font-medium' : 'text-charcoal-300');
counter.textContent = `${rows} / ${IAM_LIMITS.tagsPerResource} tags`;
},
_setStatus(message) {
const el = document.getElementById('iam-tag-status');
if (!message) {
el.classList.add('hidden');
el.textContent = '';
return;
}
el.className = 'border rounded-lg px-4 py-3 text-sm bg-red-50 border-red-200 text-red-800';
el.textContent = message;
el.classList.remove('hidden');
},
/**
* 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.
*/
_diff(current) {
const original = this._state.tags || [];
const originalByKey = new Map(original.map(tag => [tag.Key.toLowerCase(), tag]));
const currentKeys = new Set(current.map(tag => tag.Key.toLowerCase()));
const set = current.filter(tag => {
const before = originalByKey.get(tag.Key.toLowerCase());
return !before || before.Key !== tag.Key || (before.Value || '') !== (tag.Value || '');
});
const remove = original
.filter(tag => !currentKeys.has(tag.Key.toLowerCase()))
.map(tag => tag.Key);
return { set, remove };
},
async save() {
const state = this._state;
if (!state) return;
// iamCollectTags drops keyless rows, so a value typed without a key
// would silently vanish. Catch that before it does.
const orphanValue = Array.from(document.querySelectorAll('#iam-tag-rows [data-iam-row="tag"]'))
.some(row => !row.querySelector('[data-tag-key]').value.trim() &&
row.querySelector('[data-tag-value]').value.trim());
if (orphanValue) { this._setStatus('Every tag needs a key.'); return; }
const current = iamCollectTags('iam-tag-rows');
const error = iamValidateTags(current);
if (error) { this._setStatus(error); return; }
const { set, remove } = this._diff(current);
if (!set.length && !remove.length) {
showToast('No tag changes to save', 'info');
this.close();
return;
}
const btn = document.getElementById('iam-tag-save-btn');
setLoading(btn, true);
try {
await state.onSave({ set, remove });
this.close();
} catch (err) {
console.error('Error saving tags:', err);
this._setStatus(iamErrorText(err));
} finally {
setLoading(btn, false);
}
}
};